diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 78e12215504..9bb42c0094c 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -200,7 +200,10 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(opts?: { deadlineMs?: number }): Promise { + async listProcesses(opts?: { + deadlineMs?: number + signal?: AbortSignal + }): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all( diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index 8735a2efe67..22b0513bc23 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -16,13 +16,18 @@ import { PtyProcessListAdmission } from '../providers/pty-process-list-admission import type { PtyProcessInfo } from '../providers/types' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' import { + createWslGuestProcessIndexes, readWslGuestProcessInventory, resolveWslGuestForegroundProcess, + WSL_GUEST_INVENTORY_MAX_CONCURRENCY, type WslGuestProcessInventoryRead } from '../providers/wsl-guest-process-inventory' export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { - async listProcesses(opts?: { deadlineMs?: number }): Promise { + async listProcesses(opts?: { + deadlineMs?: number + signal?: AbortSignal + }): Promise { // Why: snapshotted before the request so ids spawned mid-flight can never // be reconciled away below. const preRequestActiveIds = new Set(this.activeSessionIds) @@ -60,12 +65,33 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti .filter((session) => session.wslShellAnchor) .map((session) => session.wslDistro as string) ) + const distroList = [...distros] + let nextDistroIndex = 0 + const readNextDistro = async (): Promise => { + while (nextDistroIndex < distroList.length) { + const distro = distroList[nextDistroIndex++]! + wslByDistro.set( + distro, + await readWslGuestProcessInventory(distro, { + deadlineMs: opts?.deadlineMs, + signal: opts?.signal + }) + ) + } + } await Promise.all( - [...distros].map(async (distro) => { - wslByDistro.set(distro, await readWslGuestProcessInventory(distro)) - }) + Array.from( + { length: Math.min(WSL_GUEST_INVENTORY_MAX_CONCURRENCY, distroList.length) }, + () => readNextDistro() + ) ) } + const indexesByDistro = new Map>() + for (const [distro, inventory] of wslByDistro) { + if (inventory.status === 'ok') { + indexesByDistro.set(distro, createWslGuestProcessIndexes(inventory.inventory)) + } + } for (const session of result.sessions) { if (!session.isAlive) { continue @@ -75,7 +101,12 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti const inventory = session.wslDistro ? wslByDistro.get(session.wslDistro) : undefined const resolution = session.wslDistro && session.wslShellAnchor && inventory?.status === 'ok' - ? resolveWslGuestForegroundProcess(inventory.inventory, session.wslShellAnchor) + ? resolveWslGuestForegroundProcess( + inventory.inventory, + session.wslShellAnchor, + indexesByDistro.get(session.wslDistro) ?? + createWslGuestProcessIndexes(inventory.inventory) + ) : null const foregroundProcessEvidence: ForegroundProcessEvidence | undefined = session.wslDistro ? resolution?.status === 'live' diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index f0e183bcb20..1d8bd439936 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -206,7 +206,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(opts?: { deadlineMs?: number }): Promise { + async listProcesses(opts?: { + deadlineMs?: number + signal?: AbortSignal + }): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses(opts)) ) diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index daab96101e6..62bd744c759 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -1,10 +1,21 @@ import type { IPtyProvider } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { ptyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' -import { getProviderForPty, localProvider } from '../provider/registry' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' @@ -206,6 +217,68 @@ export function hasPtyFromRuntimeController( } } +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps, + opts?: { deadlineMs?: number; signal?: AbortSignal } +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { + processes: await provider.listProcesses(opts), + hostId + } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null, + opts?: { deadlineMs?: number; signal?: AbortSignal } +) { + if (connectionId === null) { + return localProvider.listProcesses(opts) + } + if (connectionId !== undefined) { + try { + return await getProvider(connectionId).listProcesses(opts) + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } + } + return (await listProcessesWithHostScopeFromRuntimeController(deps, opts)).processes +} + export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { try { getProviderForPty(ptyId).resize(ptyId, cols, rows) @@ -240,7 +313,7 @@ export function getSizeFromRuntimeController(ptyId: string) { export async function serializeProviderBufferFromRuntimeController( ptyId: string, - opts?: { scrollbackRows?: number } + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ) { try { // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. diff --git a/src/main/providers/local-pty-provider-state.ts b/src/main/providers/local-pty-provider-state.ts index a63c5e993f7..87a0b2dfdac 100644 --- a/src/main/providers/local-pty-provider-state.ts +++ b/src/main/providers/local-pty-provider-state.ts @@ -4,6 +4,7 @@ import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' +import { resetWslGuestProcessInventory } from './wsl-guest-process-inventory' export type PtyShutdownOperation = { promise: Promise @@ -132,6 +133,7 @@ export function clearPtyState(id: string): void { ptyTerminationMode.delete(id) ptyReportsChildExitStatus.delete(id) ptyPhysicalExits.delete(id) + resetWslGuestProcessInventory() } /** diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d08e437add3..74b45cadf3c 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -136,8 +136,8 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - listProcesses(): Promise { - return listLocalPtyProcesses() + listProcesses(opts?: { deadlineMs?: number; signal?: AbortSignal }): Promise { + return listLocalPtyProcesses(opts) } getDefaultShell(): Promise { diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 748b9334a0e..80c8b4b0086 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -24,8 +24,10 @@ import { import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtyProcessInfo } from './types' import { + createWslGuestProcessIndexes, readWslGuestProcessInventory, resolveWslGuestForegroundProcess, + WSL_GUEST_INVENTORY_MAX_CONCURRENCY, type WslGuestProcessInventoryRead } from './wsl-guest-process-inventory' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' @@ -122,7 +124,10 @@ export function closeLocalPtyStartupQueryAuthority(id: string): number { return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0 } -export async function listLocalPtyProcesses(): Promise { +export async function listLocalPtyProcesses(opts?: { + deadlineMs?: number + signal?: AbortSignal +}): Promise { const entries = Array.from(ptyProcesses.entries()) const evidenceEpoch = Date.now() const wslByDistro = new Map() @@ -137,11 +142,31 @@ export async function listLocalPtyProcesses(): Promise { } } const inventories = new Map() + const distros = [...wslByDistro.keys()] + let nextDistroIndex = 0 + const readNextDistro = async (): Promise => { + while (nextDistroIndex < distros.length) { + const distro = distros[nextDistroIndex++]! + inventories.set( + distro, + await readWslGuestProcessInventory(distro, { + deadlineMs: opts?.deadlineMs, + signal: opts?.signal + }) + ) + } + } await Promise.all( - [...wslByDistro.keys()].map(async (distro) => { - inventories.set(distro, await readWslGuestProcessInventory(distro)) - }) + Array.from({ length: Math.min(WSL_GUEST_INVENTORY_MAX_CONCURRENCY, distros.length) }, () => + readNextDistro() + ) ) + const indexesByDistro = new Map>() + for (const [distro, read] of inventories) { + if (read.status === 'ok') { + indexesByDistro.set(distro, createWslGuestProcessIndexes(read.inventory)) + } + } return entries.flatMap(([id, proc]) => { // Inventory reads are asynchronous; a PTY may have exited while they ran. @@ -157,7 +182,11 @@ export async function listLocalPtyProcesses(): Promise { const anchor = ptyWslShellAnchors.get(id) const resolution = read?.status === 'ok' && anchor - ? resolveWslGuestForegroundProcess(read.inventory, anchor) + ? resolveWslGuestForegroundProcess( + read.inventory, + anchor, + indexesByDistro.get(distro) ?? createWslGuestProcessIndexes(read.inventory) + ) : { status: 'unverifiable' as const, reason: read?.status === 'unverifiable' ? read.reason : 'anchor_missing' diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 573a47670b4..cfdea43b11b 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -232,6 +232,7 @@ export type IPtyProvider = { // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. listProcesses(opts?: { deadlineMs?: number + signal?: AbortSignal includeForegroundProcessEvidence?: boolean }): Promise getDefaultShell(): Promise diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 70c01b5a1c7..37f42c5cede 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -26,8 +26,17 @@ import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-p import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. -function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { - return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } +function relayTimeoutOptions( + deadlineMs: number | undefined, + signal?: AbortSignal +): { timeoutMs?: number; signal?: AbortSignal } | undefined { + if (deadlineMs === undefined && signal === undefined) { + return undefined + } + return { + ...(deadlineMs === undefined ? {} : { timeoutMs: Math.max(1, deadlineMs - Date.now()) }), + ...(signal ? { signal } : {}) + } } /** Remote PTY provider that proxies IPtyProvider operations through the relay. */ @@ -268,13 +277,14 @@ export class SshPtyProvider implements IPtyProvider { async listProcesses(opts?: { deadlineMs?: number includeForegroundProcessEvidence?: boolean + signal?: AbortSignal }): Promise { const result = await this.mux.request( 'pty.listProcesses', opts?.includeForegroundProcessEvidence === undefined ? undefined : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, - relayTimeoutOptions(opts?.deadlineMs) + relayTimeoutOptions(opts?.deadlineMs, opts?.signal) ) const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) for (const process of processes) { diff --git a/src/main/providers/wsl-guest-foreground-process-resolution.ts b/src/main/providers/wsl-guest-foreground-process-resolution.ts index e8959cdd618..f76544aa1eb 100644 --- a/src/main/providers/wsl-guest-foreground-process-resolution.ts +++ b/src/main/providers/wsl-guest-foreground-process-resolution.ts @@ -11,14 +11,54 @@ export type WslGuestForegroundResolution = | { status: 'live'; processName: string | null; anchor: WslGuestProcessAnchor } | { status: 'unverifiable'; reason: string } +export type WslGuestProcessIndexes = { + byPid: ReadonlyMap + byForegroundGroup: ReadonlyMap + multiplexerRows: readonly WslGuestProcessRow[] +} + function normalizeTty(tty: string): string { return tty.startsWith('/dev/') ? tty : tty === '?' ? '' : `/dev/${tty}` } +function foregroundGroupKey(pgid: number, tty: string): string { + return `${pgid}\u0000${normalizeTty(tty)}` +} + +const isMultiplexerCommand = (command: string): boolean => + /(?:^|\s)(?:tmux|screen)(?:\s|$)/.test(command) + +/** Build the indexes shared by every pane resolution for one inventory. */ +export function createWslGuestProcessIndexes( + inventory: WslGuestProcessInventory +): WslGuestProcessIndexes { + const byPid = new Map() + const groups = new Map() + const multiplexerRows: WslGuestProcessRow[] = [] + for (const row of inventory.rows) { + // Preserve the resolver's historical `rows.find(pid)` first-match rule. + if (!byPid.has(row.pid)) { + byPid.set(row.pid, row) + } + const key = foregroundGroupKey(row.pgid, row.tty) + const group = groups.get(key) + if (group) { + group.push(row) + } else { + groups.set(key, [row]) + } + if (isMultiplexerCommand(row.command)) { + multiplexerRows.push(row) + } + } + return { byPid, byForegroundGroup: groups, multiplexerRows } +} + /** Correlate one shell anchor to its foreground group and strict agent recognizer. */ export function resolveWslGuestForegroundProcess( inventory: WslGuestProcessInventory, - anchor: WslGuestProcessAnchor + anchor: WslGuestProcessAnchor, + indexes: WslGuestProcessIndexes = createWslGuestProcessIndexes(inventory) ): WslGuestForegroundResolution { if (inventory.distro.toLowerCase() !== anchor.distro.toLowerCase()) { return { status: 'unverifiable', reason: 'distro_mismatch' } @@ -26,7 +66,7 @@ export function resolveWslGuestForegroundProcess( if (inventory.bootId !== anchor.bootId) { return { status: 'unverifiable', reason: 'boot_id_mismatch' } } - const shell = inventory.rows.find((row) => row.pid === anchor.shellPid) + const shell = indexes.byPid.get(anchor.shellPid) if (!shell) { return { status: 'unverifiable', reason: 'anchor_missing' } } @@ -40,20 +80,15 @@ export function resolveWslGuestForegroundProcess( if (shell.tpgid <= 0) { return { status: 'unverifiable', reason: 'foreground_group_missing' } } - const group = inventory.rows.filter( - (row) => row.pgid === shell.tpgid && normalizeTty(row.tty) === tty - ) + const group = indexes.byForegroundGroup.get(foregroundGroupKey(shell.tpgid, tty)) ?? [] if (group.length === 0) { return { status: 'unverifiable', reason: 'foreground_group_missing' } } // Multiplexers move the real command to another PTY/session. Without a // session-aware anchor, the outer shell cannot make a truthful claim. - const isMultiplexer = (command: string): boolean => - /(?:^|\s)(?:tmux|screen)(?:\s|$)/.test(command) - if (group.some((row) => isMultiplexer(row.command))) { + if (group.some((row) => isMultiplexerCommand(row.command))) { return { status: 'unverifiable', reason: 'multiplexer_boundary' } } - const byPid = new Map(inventory.rows.map((row) => [row.pid, row])) const isShellDescendant = (row: WslGuestProcessRow): boolean => { const seen = new Set() let current: WslGuestProcessRow | undefined = row @@ -62,17 +97,13 @@ export function resolveWslGuestForegroundProcess( return true } seen.add(current.pid) - current = byPid.get(current.ppid) + current = indexes.byPid.get(current.ppid) } return false } if ( - inventory.rows.some( - (row) => - row.pid !== shell.pid && - normalizeTty(row.tty) !== tty && - isMultiplexer(row.command) && - isShellDescendant(row) + indexes.multiplexerRows.some( + (row) => row.pid !== shell.pid && normalizeTty(row.tty) !== tty && isShellDescendant(row) ) ) { return { status: 'unverifiable', reason: 'multiplexer_boundary' } diff --git a/src/main/providers/wsl-guest-process-inventory.test.ts b/src/main/providers/wsl-guest-process-inventory.test.ts index ac4a4ae1c3c..f797b066099 100644 --- a/src/main/providers/wsl-guest-process-inventory.test.ts +++ b/src/main/providers/wsl-guest-process-inventory.test.ts @@ -166,6 +166,31 @@ describe('WSL guest process inventory', () => { ).toEqual({ status: 'unverifiable', reason: 'pid_reused' }) }) + it('resolves against a prebuilt inventory index without rescanning rows', () => { + const inventory = parseWslGuestProcessInventoryPayload( + payload( + [ + 'row 100 90 90 100 100 pts/0 Ss+ 12345 bash', + 'row 101 100 100 101 101 pts/0 Sl+ 54321 codex' + ].join('\n'), + 2 + ), + 'Ubuntu' + ) + const indexes = { + byPid: new Map(), + byForegroundGroup: new Map(), + multiplexerRows: [] + } + expect( + resolveWslGuestForegroundProcess( + inventory, + { distro: 'Ubuntu', bootId, shellPid: 100, shellStartTime: 12345, tty: '/dev/pts/0' }, + indexes + ) + ).toEqual({ status: 'unverifiable', reason: 'anchor_missing' }) + }) + it('does not claim identity across a multiplexer boundary', () => { const inventory = parseWslGuestProcessInventoryPayload( payload( @@ -212,6 +237,55 @@ describe('WSL guest process inventory', () => { expect(calls).toBe(3) }) + it('bounds the derived inventory cache and evicts the least-recently-used distro', async () => { + let calls = 0 + const reader = createWslGuestProcessInventoryReader({ + run: async (distro) => { + calls += 1 + return { status: 'ok', inventory: { distro, bootId, rows: [] } } + } + }) + for (let index = 0; index < 40; index += 1) { + await reader.read(`distro-${index}`) + } + expect(calls).toBe(40) + await reader.read('distro-0') + expect(calls).toBe(41) + await reader.read('distro-39') + expect(calls).toBe(41) + }) + + it('passes caller cancellation and deadline through to the guest probe', async () => { + let observedOpts: { deadlineMs?: number; signal?: AbortSignal } | undefined + const run = vi.fn( + async (distro: string, opts?: { deadlineMs?: number; signal?: AbortSignal }) => { + observedOpts = opts + return { status: 'ok' as const, inventory: { distro, bootId, rows: [] } } + } + ) + const reader = createWslGuestProcessInventoryReader({ run, now: () => 0 }) + const signal = new AbortController().signal + await reader.read('Ubuntu', { deadlineMs: 1234, signal }) + expect(run).toHaveBeenCalledOnce() + expect(observedOpts?.deadlineMs).toBe(1234) + expect(observedOpts?.signal).toBe(signal) + }) + + it('bounds the guest process timeout by the caller deadline', async () => { + runProcessMock.mockResolvedValue({ code: 127, stdout: '', stderr: '', timedOut: false }) + resetWslGuestProcessInventoryForTests() + const signal = new AbortController().signal + const deadlineMs = Date.now() + 1_000 + await readWslGuestProcessInventory('Ubuntu', { deadlineMs, signal }) + const spec = runProcessMock.mock.calls[0]?.[0] as { + timeoutMs?: number + signal?: AbortSignal + } + expect(spec.timeoutMs).toBeGreaterThan(0) + expect(spec.timeoutMs).toBeLessThanOrEqual(1_000) + expect(spec.signal).toBe(signal) + }) + it.each([1, 8, 32])('uses one guest inventory for a %s-pane burst', async (paneCount) => { let calls = 0 const reader = createWslGuestProcessInventoryReader({ diff --git a/src/main/providers/wsl-guest-process-inventory.ts b/src/main/providers/wsl-guest-process-inventory.ts index 06635872ff7..1dac6261bf0 100644 --- a/src/main/providers/wsl-guest-process-inventory.ts +++ b/src/main/providers/wsl-guest-process-inventory.ts @@ -12,10 +12,14 @@ export type { WslGuestProcessInventory, WslGuestProcessRow } from './wsl-guest-process-inventory-parser' -export { resolveWslGuestForegroundProcess } from './wsl-guest-foreground-process-resolution' +export { + createWslGuestProcessIndexes, + resolveWslGuestForegroundProcess +} from './wsl-guest-foreground-process-resolution' export type { WslGuestForegroundResolution, - WslGuestProcessAnchor + WslGuestProcessAnchor, + WslGuestProcessIndexes } from './wsl-guest-foreground-process-resolution' export type WslGuestProcessInventoryRead = @@ -33,6 +37,8 @@ export type WslGuestProcessInventoryFailureReason = const INVENTORY_TIMEOUT_MS = 5_000 const INVENTORY_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 const INVENTORY_TTL_MS = 500 +export const WSL_GUEST_INVENTORY_MAX_CONCURRENCY = 4 +const INVENTORY_CACHE_MAX_DISTROS = 32 const CAPTURE_NONCE_ENV = 'ORCA_WSL_CAPTURE_NONCE' /** @@ -83,40 +89,82 @@ export const WSL_GUEST_INVENTORY_SCRIPT = [ ].join('\n') type ReaderDeps = { - run?: (distro: string) => Promise + run?: ( + distro: string, + opts?: { deadlineMs?: number; signal?: AbortSignal } + ) => Promise now?: () => number ttlMs?: number } +export type WslGuestProcessInventoryReadOptions = { + deadlineMs?: number + signal?: AbortSignal +} + /** Construct a per-distro single-flight/TTL reader; exported for deterministic tests. */ export function createWslGuestProcessInventoryReader(deps: ReaderDeps = {}): { - read: (distro: string) => Promise + read: ( + distro: string, + opts?: WslGuestProcessInventoryReadOptions + ) => Promise reset: () => void } { const now = deps.now ?? (() => Date.now()) const ttlMs = deps.ttlMs ?? INVENTORY_TTL_MS const cached = new Map() const inFlight = new Map>() + let resetGeneration = 0 const run = deps.run ?? runWslGuestProcessInventory - const read = (distro: string): Promise => { + const read = ( + distro: string, + opts?: WslGuestProcessInventoryReadOptions + ): Promise => { const cleanedDistro = distro.trim() const key = cleanedDistro.toLowerCase() + const currentTime = now() + for (const [cachedKey, entry] of cached) { + if (currentTime - entry.at >= ttlMs) { + cached.delete(cachedKey) + } + } const prior = cached.get(key) - if (prior && now() - prior.at < ttlMs) { + if (prior) { + // Touch the entry so the map order is a true LRU order while retaining + // the completion timestamp used by the TTL. + cached.delete(key) + cached.set(key, prior) return Promise.resolve(prior.value) } + if ( + opts?.signal?.aborted || + (opts?.deadlineMs !== undefined && opts.deadlineMs <= currentTime) + ) { + return Promise.resolve({ status: 'unverifiable', reason: 'capture_timed_out' }) + } const active = inFlight.get(key) if (active) { return active } - const pending = run(cleanedDistro) + const generationAtStart = resetGeneration + const pending = run(cleanedDistro, opts) .catch((): WslGuestProcessInventoryRead => ({ status: 'unverifiable', reason: 'capture_failed' })) .then((value) => { + if (generationAtStart !== resetGeneration) { + return value + } cached.set(key, { value, at: now() }) + while (cached.size > INVENTORY_CACHE_MAX_DISTROS) { + const oldest = cached.keys().next().value + if (oldest === undefined) { + break + } + cached.delete(oldest) + } return value }) .finally(() => { @@ -130,13 +178,20 @@ export function createWslGuestProcessInventoryReader(deps: ReaderDeps = {}): { return { read, reset: () => { + resetGeneration += 1 cached.clear() inFlight.clear() } } } -async function runWslGuestProcessInventory(distro: string): Promise { +async function runWslGuestProcessInventory( + distro: string, + opts?: WslGuestProcessInventoryReadOptions +): Promise { + if (opts?.signal?.aborted || (opts?.deadlineMs !== undefined && opts.deadlineMs <= Date.now())) { + return { status: 'unverifiable', reason: 'capture_timed_out' } + } const captureNonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` const captured = buildWslCapturedLoginShellCommand(WSL_GUEST_INVENTORY_SCRIPT, captureNonce, { nonceEnvVar: CAPTURE_NONCE_ENV @@ -162,13 +217,17 @@ async function runWslGuestProcessInventory(distro: string): Promise { - return defaultReader.read(distro) + return defaultReader.read(distro, opts) } -export function resetWslGuestProcessInventoryForTests(): void { +export function resetWslGuestProcessInventory(): void { defaultReader.reset() } + +export const resetWslGuestProcessInventoryForTests = resetWslGuestProcessInventory diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f6a1ff5ac19..3c6670d22ae 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,58 +1,44566 @@ -import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' -import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' -import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' +/* 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 { 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_BRACKETED_PASTE_END, + 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 { 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 { + 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 { 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 { 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 { 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 { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions, + getWorktreeMirrorDistro, + getLocalProjectWorktreeGitOptionsForRuntime, + 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, + shouldSetDisplayName, + 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' -class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter {} -type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost -const OrcaRuntimeServiceExport = OrcaRuntimeService as unknown as { - new (...args: ConstructorParameters): OrcaRuntimeServiceExport - readonly prototype: OrcaRuntimeServiceExport +function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { + console.warn(`[project-groups] ${context}`, error) + return 'Repository could not be imported' } -export { OrcaRuntimeServiceExport as OrcaRuntimeService } -installRuntimeLinearCommandSurface(OrcaRuntimeServiceExport.prototype) -export type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' -export type { - RuntimeAutomationCreateInput, - RuntimeAutomationUpdateInput -} from './runtime-automation-controller' -export type { SubscriptionRegistration } from './runtime-subscription-registry' -export type { - OrchestrationCompatibilityCallerAuthority, - OrchestrationCompatibilityTerminalAuthority, - RuntimePtyDataAdmission, - RuntimeTerminalAgentStatusEvent -} from './runtime-terminal-contracts' -export type { MessageWaitResult } from './runtime-message-waiters' -export type { AccountsSnapshot, CodexRateLimitResetRpcResult } from './runtime-account-controller' -export type { - MobileNotificationDispatchEvent, - MobileNotificationDismissEvent, - MobileNotificationEvent -} from './runtime-mobile-notification-controller' -export type { RuntimeTerminalDataMeta } from './runtime-terminal-stream-consumers' -export type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' -export { - computeTerminalTailWaitState, - tailGainedNewerBlockedReason, - type TerminalTailWaitState -} from './terminal-wait-tail-state' -export { appendNormalizedToTailBuffer } from './terminal-tail-buffer' -export { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' -export { buildPreview } from './terminal-tail-state' -export { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' -export { projectTerminalTailLines } from './orca-runtime-terminal-projection' -export { resolveWorktreeScanCacheTtlMs } from './runtime-worktree-scan-cache' -export type { - RuntimeWorktreeLifecycleEvent, - DriverState, - PtyLayoutTarget, - PtyLayoutState, - ApplyLayoutResult, - RuntimeRendererReloadFence -} from './orca-runtime-core' -export { - AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, - WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS, - WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS -} from './orca-runtime-postlude' +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 +} + +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; signal?: AbortSignal } + ): Promise + listProcessesWithHostScope?(opts?: { deadlineMs?: number; signal?: AbortSignal }): 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 }) + ) + }) +} + +// Why not setTimeout(0): it costs a full ~15.19 ms Windows timer tick per chunk (~0.95 s/MB) +// and never bought backpressure -- 16 KiB per tick paces ~1.07 MB/s, 11x above ConPTY's +// ~96 KB/s drain, so the in-flight buffer grew regardless. setImmediate keeps the only thing +// the yield actually did (let abort/permission/data callbacks run between chunks) at ~0.01 ms, +// and TERMINAL_INPUT_MAX_BYTES still bounds what can be in flight either way. +// Why the global and not node:timers/promises: only the global is intercepted by fake timers, +// so a chunked paste stays observable on the test clock. +function yieldBetweenTerminalInputChunks(): Promise { + return new Promise((resolve) => { + setImmediate(resolve) + }) +} + +async function waitForAgentPromptDelay(delayMs: number, signal?: AbortSignal): Promise { + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + return + } + assertAgentPromptRequestActive(signal) + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('request_aborted')) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} + +const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 +// Why: the split already failed; the caller waits on this teardown only to learn whether the +// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection. +const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000 +const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000 +const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 +const SSH_PANE_RECOVERY_GRACE_MS = 30_000 +// Why: long enough that a keystroke burst to a proven-dead leaf probes once, +// short enough that a recreated session id regains writability quickly even if +// its runtime record (which also invalidates the verdict) is late. +const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000 + +function isClientDisconnectedError(error: unknown): boolean { + return error instanceof Error && error.message === 'client_disconnected' +} + +function createTerminalRevealWarning(handle: string, error?: unknown): string { + const reason = + error instanceof Error && error.message.trim().length > 0 + ? ` Reason: ${error.message.trim()}.` + : '' + return [ + `Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`, + `Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.` + ].join(' ') +} + +// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit +// the key rather than send `true`. +function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } { + return shouldSurface ? {} : { surfaceOwner: false } +} + +function resolveTerminalPresentation(opts: { + presentation?: RuntimeTerminalPresentation + focus?: boolean + activate?: boolean +}): RuntimeTerminalPresentation | undefined { + if (opts.presentation) { + return opts.presentation + } + if (opts.focus === true || opts.activate === true) { + return 'focused' + } + return undefined +} + +type RuntimeNotifier = { + worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void + worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void + worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void + reposChanged(): void + automationsChanged?(payload: AutomationsChangedPayload): void + activateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void + createTerminal( + worktreeId: string, + opts: { + command?: string + cwd?: string + env?: Record + title?: string + presentation?: RuntimeTerminalPresentation + } + ): void + revealTerminalSession?( + worktreeId: string, + opts: { + ptyId: string + title?: string | null + cwd?: string + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + activate?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + focus?: boolean + expectedProcessIdentity?: { + terminalHandle: string + incarnationId: string + } + } + ): + | Promise<{ tabId: string; title?: string | null; identity?: TerminalRevealIdentity }> + | { tabId: string; title?: string | null; identity?: TerminalRevealIdentity } + | void + resolveLegacyWorkerTerminalRecovery?( + paneKey: string, + resolution: 'adopted' | 'exited' | 'rolled_back', + ptyId?: string + ): void + splitTerminal( + tabId: string, + paneRuntimeId: number, + opts: { + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + } + ): void + renameTerminal(tabId: string, title: string | null): void + focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void + focusEditorTab?(tabId: string, worktreeId: string): void + closeSessionTab?(tabId: string, worktreeId: string): void | Promise + moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void + openFile?( + worktreeId: string, + filePath: string, + relativePath: string, + runtimeEnvironmentId?: string | null + ): void + openDiff?( + worktreeId: string, + filePath: string, + relativePath: string, + staged: boolean, + runtimeEnvironmentId?: string | null + ): void + readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise + saveMobileMarkdownTab?( + worktreeId: string, + tabId: string, + baseVersion: string, + content: string + ): Promise + closeTerminal(tabId: string, paneRuntimeId?: number): void + closeTerminalTab?( + tabId: string, + options?: { localPtyTeardownOwnedExternally?: boolean } + ): Promise + sleepWorktree(worktreeId: string): void + // Why: a phone opening a worktree wakes its slept agents by asking the host + // renderer to run its own navigation-free wake (experimental agent sleep); + // the runtime has no in-memory sleeping records or wake authority. Optional to + // match the many renderer-backed notifier methods only the real bridge wires. + resumeSleepingAgents?(worktreeId: string): void + terminalFitOverrideChanged( + ptyId: string, + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', + cols: number, + rows: number + ): void + // Why: presence-based lock signal — desktop renderer mounts the lock + // banner when `driver.kind === 'mobile'` and unmounts otherwise. The + // structured payload (vs a `locked: boolean`) carries the active mobile + // actor's clientId so the renderer can disambiguate multi-phone scenarios + // and so a future write coordinator can use the same signal as scheduling + // input. See docs/mobile-presence-lock.md. + terminalDriverChanged(ptyId: string, driver: DriverState): void + nativeChatLaunchDraftResolved?( + tabId: string, + resolution: { text: string; createdAt: number } + ): void + browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void + // Why: separate from the driver above because watching and driving are independent — a page can + // be watched by a desktop client with no driver at all, and that page must still paint. + browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void + // Why: pages placed on a paired client never reach the host renderer's tab model, so the host + // has no row for them unless main pushes one. Ephemeral and host-local — see + // src/shared/client-hosted-browser-rows.ts. + clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void +} + +type TerminalHandleRecord = { + handle: string + runtimeId: string + rendererGraphEpoch: number + worktreeId: string + tabId: string + leafId: string + ptyId: string | null + ptyGeneration: number +} + +type PtyIncarnationHandleRecord = { + handle: string + incarnationId: string + leafKey: string +} + +export type OrchestrationCompatibilityTerminalAuthority = { + runtimeId: string + terminalHandle: string + ptyId: string + worktreeId: string + processIncarnation: string | null + paneKey: string | null + launchTokenHash: string | null + hostScope: WorkerTerminalHostScope +} + +export type LegacyWorkerTerminalRecoveryResult = { + blockedPaneCount: number + adoptedDispatchIds: string[] + exitedDispatchIds: string[] + deferredDispatchIds: string[] +} + +type LegacyWorkerTerminalRecoveryResolution = { + candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] + resolution: 'adopted' | 'exited' +} + +export type OrchestrationCompatibilityCallerAuthority = Readonly<{ + hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] + paneKey: string + terminalHandle: string + processIncarnation: string + launchTokenHash: string +}> + +type RestoredOrchestrationAuthorityReceipt = Readonly<{ + ptyId: string + worktreeId: string + terminalHandle: string + paneKey: string + processIncarnation: string + hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] +}> + +type OrchestrationCompatibilitySshAttachmentAuthority = Extract< + OrchestrationCompatibilityHostStamp, + { kind: 'ssh' } +> + +type TerminalWaiter = { + handle: string + condition: RuntimeTerminalWaitCondition + resolve: (result: RuntimeTerminalWait) => void + reject: (error: Error) => void + timeout: NodeJS.Timeout | null + pollInterval: NodeJS.Timeout | null + abortCleanup: (() => void) | null +} + +type MessageWaiter = OrchestrationMessageWaiter & { + handle: string + resolve: (result: MessageWaitResult) => void + timeout: NodeJS.Timeout | null + abortCleanup: (() => void) | null +} + +export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' + +function omitUndefinedProperties>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as Partial +} + +async function isRuntimeWorktreePathMissing( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) +} + +async function isLocalRuntimeGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +function gitStatusErrorMeansNotRepository(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr) + : '' + return /not a git repository/i.test(`${message}\n${stderr}`) +} + +type RuntimeWorktreeRemovalTarget = { + id: string + repoId: string + path: string + pushTarget?: GitPushTarget +} + +type RuntimeWorktreeRemovalInFlight = { + optionsKey: string + promise: Promise +} + +type PreservedBranchCleanupTarget = { + worktreeId: string + hostId?: ExecutionHostId + branchName: string + head: string + pushTarget?: GitPushTarget +} + +function getRuntimeWorktreeRemovalOptionsKey( + force: boolean, + runHooks: boolean, + allowUnverifiedPtyStop: boolean +): string { + // Why: a forced retry must not coalesce onto the in-flight attempt that just + // failed the PTY gate — it would inherit that failure instead of retrying. + const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop' + return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}` +} + +// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime +// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo +// (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. +function runtimeRepoMatchesExecutionHost( + repo: Pick, + executionHostId?: ExecutionHostId | null +): boolean { + if (executionHostId == null) { + return true + } + if (repo.executionHostId != null) { + return repo.executionHostId === executionHostId + } + return repo.connectionId == null +} + +// Why: this runtime only has local git and local fs, so an ssh: host here would clone and +// probe the wrong machine and then register the result as remote. SSH setup is owned by the +// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; +// only `local` and `runtime:` legitimately reach these RPCs. +function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { + if (parseExecutionHostId(hostId)?.kind !== 'ssh') { + return + } + throw new Error( + 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' + ) +} + +function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +function listRuntimeFolderWorkspaces( + store: Pick, + repo: Repo, + repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length +): Worktree[] { + const rootId = getRuntimeFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const expectedHostId = getRepoExecutionHostId(repo) + const ids = Object.keys(allMeta).filter( + (worktreeId) => + isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) && + (repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } else { + ids.sort((left, right) => { + if (left === rootId) { + return -1 + } + if (right === rootId) { + return 1 + } + return 0 + }) + } + + return ids.map((worktreeId) => { + const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) + const meta: Partial = existing?.instanceId + ? existing + : existing || repoOwnerCount === 1 + ? store.setWorktreeMeta(worktreeId, { + instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + : {} + return { + ...mergeRuntimeFolderWorkspace(repo, worktreeId, meta), + hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId + } + }) +} + +function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null { + const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = splitWorktreeId(worktreeId) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return null + } + return { + id: worktreeId, + repoId: parsed.repoId, + path: parsed.worktreePath + } +} + +async function resolveCreateBranchName( + repoPath: string, + branchNameOverride: string | undefined, + sanitizedName: string, + settings: { branchPrefix: string; branchPrefixCustom?: string }, + username: string | null, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!branchNameOverride) { + // The runtime store's getSettings() types branchPrefix loosely as string; + // it is always one of the BranchPrefixStrategy literals at runtime. + return computeValidatedBranchName( + sanitizedName, + { ...settings, branchPrefix: settings.branchPrefix as BranchPrefixStrategy }, + username + ) + } + if (branchNameOverride.startsWith('-')) { + throw new Error('Branch name must not start with "-"') + } + await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { + cwd: repoPath, + ...gitOptions + }) + return branchNameOverride +} + +function normalizeLocalBranchName(branchName: string | undefined): string { + return branchName?.replace(/^refs\/heads\//, '') ?? '' +} + +// Clamp terminal dimensions to the PTY's supported range (cols 20–240, rows 8–120). +function clampTerminalViewport(cols: number, rows: number): { cols: number; rows: number } { + return { + cols: Math.max(20, Math.min(240, Math.round(cols))), + rows: Math.max(8, Math.min(120, Math.round(rows))) + } +} + +// Subscribe a listener to a per-key Set, pruning the key's entry once its last +// listener unsubscribes. Returns the unsubscribe callback. +function addListenerToMap(map: Map>, key: string, listener: T): () => void { + let listeners = map.get(key) + if (!listeners) { + listeners = new Set() + map.set(key, listeners) + } + const set = listeners + set.add(listener) + return () => { + set.delete(listener) + if (set.size === 0) { + map.delete(key) + } + } +} + +async function canCheckoutExistingLocalBranch( + repoPath: string, + branchName: string, + baseBranch: string, + gitOptions: { wslDistro?: string } = {} +): Promise { + let localHead = '' + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], + { + cwd: repoPath, + ...gitOptions + } + ) + localHead = stdout.trim() + } catch { + return false + } + if (normalizeLocalBranchName(baseBranch) !== branchName) { + if (!localHead) { + return false + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], + { cwd: repoPath, ...gitOptions } + ) + if (stdout.trim() !== localHead) { + return false + } + } catch { + return false + } + } + const worktrees = await listWorktrees(repoPath, gitOptions) + return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) +} + +function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { + return Object.keys(gitOptions).length > 0 +} + +function getLocalGitHubPrForBranch( + repoPath: string, + branchName: string, + gitOptions: { wslDistro?: string } +): ReturnType { + return hasLocalGitOptions(gitOptions) + ? getPRForBranch(repoPath, branchName, null, null, null, { + localGitExecOptions: gitOptions + }) + : getPRForBranch(repoPath, branchName) +} + +async function getSelectedHostedReviewForBranch( + repo: Pick, + branchName: string, + args: SelectedReviewBranchInput, + executionOptions: { localGitExecOptions?: { wslDistro?: string } } = {} +): Promise<{ matchesSelected: boolean; number: number } | null> { + const selectedReview = getSelectedReviewBranch(args) + if (!selectedReview) { + return null + } + const review = await getHostedReviewForBranchFromRepo({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + branch: branchName, + ...executionOptions, + ...getSelectedReviewLookupHints(args) + }) + if (!review) { + return null + } + return { + matchesSelected: + review.provider === selectedReview.provider && review.number === selectedReview.number, + number: review.number + } +} + +async function pathExists(pathValue: string): Promise { + try { + await stat(pathValue) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } +} + +function resolveServerBrowsePath(pathValue: string): string { + const trimmed = pathValue.trim() || '~' + if (trimmed.includes('\0')) { + throw new Error('Path cannot contain null bytes') + } + if (trimmed === '~') { + return homedir() + } + if (/^~[\\/]/.test(trimmed)) { + return resolve(homedir(), trimmed.slice(2)) + } + if (isAbsolute(trimmed)) { + return resolve(trimmed) + } + // Why: remote clients do not share the server process cwd; relative browse + // inputs are anchored to the server user's home to match the `~` picker root. + return resolve(homedir(), trimmed) +} + +type ResolvedWorktree = Worktree & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null + git: GitWorktreeInfo +} + +type LinearAgentWriteTarget = { + issue: LinearIssueSummary + workspaceId: string +} + +type LinearCreateFieldIntent = { + stateId?: string + assigneeId?: string | null + priority?: number + estimate?: number | null + dueDate?: string | null + labelIds?: string[] + projectId?: string +} + +const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +function sameStringSet(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + const rightSet = new Set(right) + return left.every((value) => rightSet.has(value)) +} + +function labelsForIds( + ids: string[], + labels: { id?: string | null; name?: string | null; color?: string | null }[] +): { id: string; name: string; color?: string | null }[] { + return ids.map((id) => { + const label = labels.find((candidate) => candidate.id === id) + return { + id, + name: label?.name ?? id, + ...(label?.color ? { color: label.color } : {}) + } + }) +} + +type TerminalWorkspaceLaunchScope = { + id: string + path: string + connectionId: string | null + repo: Repo | null + folderWorkspace: FolderWorkspace | null +} + +type ResolvedTerminalWorkspaceLaunchTarget = { + scope: TerminalWorkspaceLaunchScope + managedWorktree: ResolvedWorktree | null +} + +type WorktreeLineageInput = { + parentWorkspace?: string + /** Set by in-app parent pickers so the row is not recorded as a CLI flag. */ + parentWorkspaceOrigin?: 'manual' + envParentWorkspace?: string + parentWorktree?: string + cwdParentWorktree?: string + noParent?: boolean + callerTerminalHandle?: string + comment?: string + orchestrationContext?: { + parentWorktreeId?: string + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + } +} + +type ResolvedWorkspaceParent = + | { + type: 'worktree' + workspaceKey: WorkspaceKey + worktree: ResolvedWorktree + instanceId: string | null + } + | { + type: 'folder' + workspaceKey: WorkspaceKey + folderWorkspace: FolderWorkspace + instanceId: string | null + } + +type WorktreeLineageResolution = + | { + kind: 'lineage' + parent: ResolvedWorkspaceParent + origin: WorktreeLineage['origin'] + capture: WorktreeLineage['capture'] + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + } + | { + kind: 'none' + warnings: WorktreeLineageWarning[] + } + +type RuntimeWorktreeScanCache = { + generation: number + runtimeKey: string + result: RuntimeWorktreeScanResult + expiresAt: number + /** + * Git-admin state read as of the scan's start, written back once the probe settles. Never holds a + * pending promise: a wedged mount would otherwise poison every later refresh that awaits it. + * `null` means "cannot prove unchanged" (unreadable layout, still probing, probe timed out). + */ + adminFingerprint: string | null + /** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */ + scannedAt: number +} + +type RuntimeWorktreeScanInFlight = { + generation: number + runtimeKey: string + promise: Promise +} + +type RuntimeWorktreeScanRefresh = { + result: RuntimeWorktreeScanResult + adminFingerprint: string | null + /** Still-running probe whose value the cache entry adopts if it settles; never awaited by a caller. */ + adminFingerprintProbe: Promise | null + scannedAt: number +} + +type WorktreeLineageCandidate = { + source: 'env-workspace' | 'cwd-context' | 'terminal-context' | 'orchestration-context' + parent: ResolvedWorkspaceParent + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string +} + +function extractOrchestrationTaskId(text?: string): string | undefined { + return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] +} + +class RuntimeLineageError extends Error { + code: string + data?: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } +} + +class WorktreeIdRequiresFullPathError extends Error { + readonly code = 'worktree_id_requires_full_path' + + constructor() { + super( + 'Worktree id selectors must use the full :: value. Use the id from `orca worktree list --json`, or target by path:, branch:, or issue:.' + ) + } +} + +type ResolvedWorktreeSnapshot = { + worktrees: ResolvedWorktree[] + platformByRepoId: ReadonlyMap +} + +type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & { + expiresAt: number +} + +type ResolvedWorktreeInFlight = { + generation: number + promise: Promise +} + +// Why: notificationSeq is the desktop-assigned monotonic sequence used for +// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a +// client can watermark the last event it delivered and request exactly the +// events after it — idempotent, no duplicate local pushes. +export type MobileNotificationDispatchEvent = { + type: 'notification' + source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' + title: string + body: string + worktreeId?: string + notificationId?: string + notificationSeq?: number + notificationEpoch?: string +} + +export type RuntimeWorktreeLifecycleEvent = + | { kind: 'created'; worktreeId: string; path: string; branch: string } + | { kind: 'removed'; worktreeId: string; path: string } + +export type MobileNotificationDismissEvent = { + type: 'dismiss' + notificationId: string + notificationSeq?: number + notificationEpoch?: string +} + +export type MobileNotificationEvent = + | MobileNotificationDispatchEvent + | MobileNotificationDismissEvent + +// Why: presence-based driver state for the mobile-presence lock. Exactly one +// driver per PTY at any moment. See docs/mobile-presence-lock.md. +// - `idle`: no mobile subscribers; desktop input flows freely +// - `desktop`: at least one mobile client subscribed but desktop reclaimed +// (or all mobile clients are passive `desktop`-mode watchers); desktop +// input flows freely +// - `mobile{clientId}`: a mobile client is the active driver; desktop +// input/resize are dropped server-side and the lock banner is mounted. +// `clientId` is the most recent mobile actor for this PTY. +export type DriverState = RuntimeTerminalDriverState + +// Why: per-PTY layout target — what the PTY *should* be at right now. +// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive +// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at +// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. +// See docs/mobile-terminal-layout-state-machine.md. +export type PtyLayoutTarget = + | { kind: 'desktop'; cols: number; rows: number } + | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + | { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string } + +// Why: authoritative layout state with monotonic seq. Bumped on every +// applyLayout success; emitted on mobile subscribe-stream events so clients +// drop stale events that arrive after a newer transition. +export type PtyLayoutState = PtyLayoutTarget & { + seq: number + appliedAt: number +} + +// Why: applyLayout result discriminator. Callers (especially RPC handlers) +// need to distinguish "shipped a new state at seq N" from "no-op — caller +// should not claim a seq it didn't produce." `pty-exited` is terminal; +// `resize-failed` is transient and the caller may retry. +export type ApplyLayoutResult = + | { ok: true; state: PtyLayoutState } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } + +type LayoutQueueEntry = { + running: Promise | null + pending: { + target: PtyLayoutTarget + waiters: ((r: ApplyLayoutResult) => void)[] + }[] +} + +type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { + worktreeId: string +} + +const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 + +async function hasLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: { wslDistro?: string } = {} +): Promise { + const refExists = (qualifiedRef: string) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasCommitObjectViaGitExec( + (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), + baseRef + ) +} + +function getSetupRunnerCommandPlatformForLaunch( + setup: CreateWorktreeResult['setup'], + fallbackPlatform: 'windows' | 'posix' +): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) +} + +export type RuntimeRendererReloadFence = Readonly<{ + revision: number + recovery: 'renderer' | 'headless' | 'reloading' +}> + +/** How a caller wants the provider-held screen fetched when the runtime has no + * bytes of its own. `visibleScreenOnly` narrows the result to the current grid: + * scrollback can still hold a ready banner a working agent printed minutes ago, + * which is history, not evidence of what the agent is doing now. */ +type ProviderSnapshotReadOptions = { + timeoutMs?: number + retireOnTimeout?: boolean + visibleScreenOnly?: boolean +} + +export class OrcaRuntimeService { + private readonly runtimeId = randomUUID() + private readonly startedAt = Date.now() + private readonly store: RuntimeStore | null + private managedHookReconciliationGeneration = 0 + private managedHookReconciliationTail: Promise = Promise.resolve() + private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null + private readonly orchestrationFederationTimers = new Map>() + private orchestrationTerminalHistoryRecoveryTimer: ReturnType | null = null + private orchestrationTerminalHistoryRecoveryInFlight: Promise | null = null + private orchestrationTerminalRecoveryRowId = 0 + private orchestrationFederationRelayGeneration = 0 + private readonly orchestrationFederationSyncs = new Map< + string, + { db: OrchestrationDb; promise: Promise } + >() + private readonly orchestrationFederationWarnings = new Set() + private rendererGraphEpoch = 0 + private graphStatus: RuntimeGraphStatus = 'unavailable' + private authoritativeWindowId: number | null = null + private headlessGraphFallbackAvailable = false + private pendingHeadlessPromotionWindowId: number | null = null + private rendererGeneration: string | null = null + private mobileSessionTabsChangeSequence = 0 + private pendingMobileSessionTabsChangeSequenceByWorktree = new Map() + private readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({ + timeoutMs: RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, + onSettled: ({ revision, windowId, outcome, durationMs }) => { + console.info( + `[runtime-graph] reload revision=${revision} window=${windowId} outcome=${outcome} durationMs=${durationMs}` + ) + }, + onTimeout: (_revision, windowId) => this.handleGraphReloadTimeout(windowId) + }) + // Why: paired graph transactions need foreground timer cadence only until their publication settles. + private readonly rendererPublicationThrottle = new RendererPublicationThrottle() + private tabs = new Map() + private mobileSessionTabsByWorktree = new Map() + private readonly clientHostedPageReconciliation = new ClientHostedPageReconciliationWindow( + Date.now() + ) + // Why: renderer publication ordering must be judged against the renderer's + // own last-accepted (epoch, version) — never against the stored snapshot's + // version, which main-local touches bump independently and can push + // permanently ahead of the renderer's counter. The renderer reuses one pair + // for byte-identical content, so a same-epoch version <= this one is a no-op + // resend (or stale) and is skipped without touching the stored entry. + private acceptedRendererMobileSnapshotByWorktree = new Map< + string, + { + publicationEpoch: string + rendererVersion: number + rendererTabCount: number + rendererTabIdentityKeys: ReadonlySet + } + >() + private clientSessionTabSelections = new ClientSessionTabSelectionStore() + // Why: idempotency map for mobile terminal creation — a retried create with the + // same clientMutationId returns the in-flight operation instead of duplicating. + private mobileTerminalCreateByMutationId = new Map< + string, + Promise + >() + private readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency() + // Why: concurrent clients sleeping one host workspace must share one physical teardown. + private terminalSleepByWorktreeId = new Map>() + private terminalMutationTailByWorktreeId = new Map>() + private terminalSleepStateByWorktreeId = new Map< + string, + { + worktreeId: string + generation: number + phase: 'stopping' | 'partial' | 'sleeping' + ptyIds: string[] + terminalHandles: string[] + terminalHandlesByPtyId: Record + } + >() + private terminalSleepGeneration = 0 + private terminalPaneRecoveryByIdentity = new Map>() + // Why: idempotency map for worktree.create — a create interrupted by a mobile + // connection migration is retried with the same clientMutationId and returns + // the in-flight (or just-finished) operation instead of a duplicate worktree. + private worktreeCreateByMutationId = new Map>() + // Why: a mobile create waits for the renderer to publish the new tab's surface + // via graph-sync, but a throttled/hidden renderer can park that past the surface + // timeout and the create would then destroy the live PTY (#7587). This lets the + // renderer's own PTY spawn publish the surface main-side, scoped to in-flight + // creates so ordinary renderer spawns never publish here. + private pendingMobileTerminalCreatesByKey = new Map< + string, + { + activate: boolean + paired: boolean + selectIfNoActiveTab: boolean + viewMode?: 'terminal' | 'chat' + /** Resolved agent launch command, kept so a settle over a bare renderer + * PTY can still deliver the launch instead of succeeding silently (STA-3214). */ + startupCommand?: string + } + >() + private mobileSessionTabListeners = new Set<{ + listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void + clientNavigationId?: string + }>() + // Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety. + private terminalTopologyRevisionByRepoId = new Map() + // Why: provider exit can beat surface registration; that exact dead incarnation must never publish. + private earlyExitedPtyIncarnations = new Map() + private pendingPtyRegistrationIncarnations = new Map() + // Why: exact-stop is the current sleep transaction boundary; its exit must + // leave the renderer's intentional sleeping surface available for wake. + private intentionalHandlelessPtyStops = new Map() + // Why: coalesces title/status-driven session.tabs emits so spinner churn + // doesn't fan out (and per-client JSON.stringify) a snapshot several times a + // second. Emit reads the latest snapshot, so only the freshest version ships. + private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = + createMobileSessionTabsNotifyCoalescer((worktreeId) => + this.flushScheduledMobileSessionTabsChanged(worktreeId) + ) + private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = + createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId), + (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) + ) + // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) + // each await a full host reveal; only one terminal can be focused, so latest-wins + // single-flight bounds host work. Does not replace cheaper activation or + // reconnect-scan bounding for sequential soft freezes. + private readonly terminalFocusNavigationCoalescer = + new TerminalFocusNavigationCoalescer() + private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = + null + private structuredAgentSessionTabRestorePromise: Promise | null = null + private structuredAgentSessionStartupRestorePromise: Promise | null = null + private leaves = new Map() + // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by + // ptyId keeps active TUI redraws independent of the total open terminal count. + private leavesByPtyId = new Map() + private handles = new Map() + private handleByLeafKey = new Map() + private handleByPtyId = new Map() + private handleByPtyIncarnation = new Map() + // A provider announces a replacement before the spawn commit can bind its + // pane. Keep the predecessor aliases fenced during that hand-off window. + private pendingPtyHandleReplacementFences = new Map< + string, + { + incarnationId: PtyIncarnationId + staleHandles: Set + pendingRegistration: boolean + } + >() + private readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) => + this.repointPendingMessagesForHandle(handle) + ) + private syntheticTerminalHandles = new Set() + private detachedPreAllocatedLeaves = new Map() + private graphSyncCallbacks: (() => void)[] = [] + private sessionTabsInventoryPublicationEpoch: number | null = null + private sessionTabsInventoryWaiters = new Set<() => void>() + private waitersByHandle = new Map>() + private ptyExitListenersByPtyId = new Map void>>() + private ptyController: RuntimePtyController | null = null + private notifier: RuntimeNotifier | null = null + private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. + private terminalSideEffectExcludedClientEventListeners = new Set< + (event: RuntimeClientEvent) => void + >() + private terminalSideEffectTitleGateKeysByClientEventListener = new Map< + (event: RuntimeClientEvent) => void, + Map + >() + private nativeChatLaunchDraftResolutionByTabId = new Map< + string, + NativeChatLaunchDraftResolutionTombstone + >() + private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() + private forkBackfillStarted = false + private agentBrowserBridge: AgentBrowserBridge | null = null + private offscreenBrowserBackend: BrowserBackend | null = null + private emulatorBridge: EmulatorBridge | null = null + private resolvedWorktreeCache: ResolvedWorktreeCache | null = null + private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null + private resolvedWorktreeGeneration = 0 + private worktreeScanGenerations = new Map() + private worktreeScanCache = new Map() + private worktreeScanInFlight = new Map() + /** Repos whose Git-admin probe has not settled yet; caps abandoned fs work at one per repo. */ + private worktreeAdminFingerprintProbes = new Set() + private cloneInFlightByPath = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyForegroundProcessReads = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() + // Why a set and not a timer: the intent is retired by the exit it explains, or + // by the next lifecycle generation on that id (advancePtyLifecycleGeneration), + // so a stop that never produced an exit cannot outlive its process. + private readonly stopRequestedPtyIds = new Set() + private _orchestrationDb: OrchestrationDb | null = null + private messageWaitersByHandle = new Map>() + private readonly orchestrationMailboxOwner = new OrchestrationMailboxOwner({ + getDb: () => this._orchestrationDb, + getLeaf: (leafKey) => this.leaves.get(leafKey), + getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), + getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey), + getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), + onRoutedMessageTypes: (mailboxHandle, types) => + this.orchestrationMailboxNotifications.wakeRoutedMessageWaiters(mailboxHandle, types), + onForeignMailboxRouted: (mailboxHandle, messageType) => + this.notifyMessageArrived(mailboxHandle, messageType) + }) + private readonly orchestrationMailboxDeliveryTarget = new OrchestrationMailboxDeliveryTarget({ + getDb: () => this._orchestrationDb, + getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), + hasTerminalHandle: (handle) => this.handles.has(handle), + canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), + controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), + isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) + }) + private readonly orchestrationMailboxPointerDelivery = + new OrchestrationMailboxPointerDelivery({ + mailboxOwner: this.orchestrationMailboxOwner, + deliveryTarget: this.orchestrationMailboxDeliveryTarget, + getDb: () => this._orchestrationDb, + getLeaf: (leafKey) => this.leaves.get(leafKey), + getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), + getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, + getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle), + getTabTitle: (tabId) => this.tabs.get(tabId)?.title, + getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey), + isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), + redriveMailbox: (mailboxHandle, reservedTypes) => + this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), + writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) + }) + private readonly orchestrationMailboxNotifications = + new OrchestrationMailboxNotificationCoordinator({ + mailboxOwner: this.orchestrationMailboxOwner, + pointerDelivery: this.orchestrationMailboxPointerDelivery, + getDb: () => this._orchestrationDb, + getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, + getPaneKeyForHandle: (handle) => { + const record = this.handles.get(handle) + return record ? `${record.tabId}:${record.leafId}` : undefined + }, + getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle), + hasTerminalHandle: (handle) => this.handles.has(handle), + deliverForHandle: (handle, reservedTypes) => + this.deliverPendingMessagesForHandle(handle, reservedTypes), + notifyMessageArrived: (handle, messageType) => this.notifyMessageArrived(handle, messageType), + resolveMessageWaiter: (waiter) => this.resolveMessageWaiter(waiter, 'notified') + }) + // Why: mobile clients subscribe to terminal output via terminal.subscribe. + // These listeners fire on every onPtyData call, enabling real-time streaming + // without polling. Keyed by ptyId for O(1) lookup per data event. + private dataListeners = new Map< + string, + Set<(data: string, meta?: RuntimeTerminalDataMeta) => void> + >() + private remoteTerminalSourceRangeConsumerHooks: RemoteTerminalSourceRangeConsumerHooks | null = + null + // Why: startup draft paste can subscribe after the agent already emitted its + // ready marker. Keep a bounded raw buffer so fast startup output is replayed. + private recentPtyOutputById = new Map() + private setupCompletionTokenByPtyId = new Map() + // Why: mobile clients need to know when the desktop restores a terminal + // from mobile-fit so they can update their UI. These listeners are + // invoked from resizeForClient and onClientDisconnected/onPtyExit. + private fitOverrideListeners = new Map< + string, + Set< + (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + > + >() + private driverListeners = new Map void>>() + private subscriptionCleanups = new Map void | Promise>() + private subscriptionCleanupPromises = new Map< + string, + { cleanup: () => void | Promise; promise: Promise } + >() + // Why: index of subscriptionIds by per-WebSocket connectionId so the + // server can sweep all subscriptions for a closing socket without + // touching subscriptions on other live sockets that share the same + // deviceToken (multi-screen mobile). + private subscriptionsByConnection = new Map>() + private subscriptionConnectionByEntry = new Map() + // Why: a connection record replaces whatever else that socket was streaming regardless of who + // is driving, so it deliberately carries no pairing scope. + private activeBrowserScreencastsByConnection = new Map< + string, + Omit + >() + private activeBrowserScreencastsByPage = new Map>() + // Why: paint retention, not control — Chromium stops painting a display:none guest, so the host + // renderer must keep any page a remote client is watching mounted even when nobody drives it. + private browserRemoteViewerPages = new Set() + // Why: mobile clients subscribe to desktop notifications via + // notifications.subscribe. This set enables fan-out — each connected + // mobile client gets its own listener, and dispatchMobileNotification + // iterates them all. Listeners are cleaned up via subscriptionCleanups. + private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() + private ptysById = new Map() + // Why a separate map: `connected` is a wire field that any inventory gap + // clears, so it cannot distinguish an observed exit from lost contact. This + // records the last liveness verdict we actually earned, and outlives the pty + // record so a close/stop receipt can still say the stop was unconfirmed. + private ptyLivenessVerdictByPtyId = new Map() + private ptyLivenessObservationSequence = 0 + // Catalog polls reuse the last controller census until a PTY lifecycle or + // output event invalidates it; this keeps idle mobile polls read-free. + private ptyLivenessRefreshRequired = false + private ptyLivenessRefreshInProgress = 0 + + private invalidatePtyLivenessSnapshot(): void { + if (this.ptyLivenessRefreshInProgress === 0) { + this.ptyLivenessRefreshRequired = true + } + } + private readonly pairedRendererSessionOwnedPtyIds = new Set() + private wslDistroByPtyId = new Map() + private titleObservationSequence = 0 + private headlessTerminals = new Map() + private ptyOutputSequenceById = new Map() + private agentPromptLifecycleByPtyId = new Map< + string, + { status: AgentStatus | null; workingSequence: number; updatedAt: number } + >() + private agentPromptPermissionSequenceByPtyId = new Map() + private agentPromptExplicitStatusFloorByPtyId = new Map() + private agentPromptSubmissionTailByPtyId = new Map>() + private providerSequenceInitializedPtys = new Set() + private providerSequenceOffsetByPtyId = new Map() + private providerSnapshotPreferredPtys = new Set() + private providerModeTrackersByPtyId = new Map() + private providerModeSnapshotScansByPtyId = new Map< + string, + Set + >() + private providerBufferAcquisitionsByPtyId = new Map() + private providerVisibleStateByPtyId = new Map() + private providerVisibleRetryAtByPtyId = new Map() + private providerSnapshotsWithLiveModeTransition = new WeakSet() + private ptyLifecycleGenerationById = new Map() + private nextPtyLifecycleGeneration = 1 + private recentPtyPathCandidatesById = new Map() + // Why: candidates only feed mobile file-tap provenance; desktop-only + // sessions skip the 3-regex extraction on every PTY chunk until a + // mobile/remote client authenticates (sticky, backfilled on activation). + private recentPtyPathCandidateTrackingActive = false + // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the + // runtime lets hidden/model-owned terminals observe agent state without a + // mounted xterm view. + // Why a throttle: the blocked-reason check builds and scans two full wait + // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost + // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are + // arbitrary, so running the identical computation over coalesced chunks at + // a bounded cadence (plus a trailing-edge timer so burst-final state is + // always evaluated) preserves semantics while removing it from the hot path. + private waitBlockedCheckStateByPtyId = new Map< + string, + { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + } + >() + + private agentStatusOscProcessorsByPtyId = new Map< + string, + ReturnType + >() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working + // timer) replace last-title-per-chunk scanning so main observes the same + // intra-chunk working→idle transitions the renderer does (issue #1083). + // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. + private ptyTitleTrackersByPtyId = new Map() + // Why: the Command Code output detector arms early from the launch command + // when known (banner detection covers user-typed launches), mirroring the + // renderer detector's startupCommand seed. + private terminalSpawnCommandsByPtyId = new Map() + // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over + // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed + // chunks into the title tracker instead of falling back to last-title scans. + private oscTitleScanTailByPtyId = new Map() + // Why: mobile file taps resolve relative paths on the host. OSC 7 is the + // terminal-owned cwd signal, and it can arrive in live output between snapshots. + private osc7ScanTailByPtyId = new Map() + private terminalCwdByPtyId = new Map() + private terminalFileUriHostnameByPtyId = new Map() + // Why: latest agent-status payload per pane, retained so worktree.ps can serve + // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty + // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. + private latestAgentStatusByPaneKey = new Map() + // Why: per-PTY hydration state guards against double-hydration. Keys: + // 'pending' → maybeHydrateHeadlessFromRenderer is in flight + // 'done' → hydration completed (success or skip); never run again + // Absent → hydration has not been considered yet for this PTY. + // See docs/mobile-prefer-renderer-scrollback.md. + private headlessHydrationState = new Map() + // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because + // handles can be reissued while the PTY identity is stable. In-memory only — + // a stale phone override should not survive an app restart. + private terminalFitOverrides = new Map< + string, + { + mode: 'mobile-fit' + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + >() + + // Why: server-authoritative display mode per terminal. 'auto' (default) + // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' + // locks to no-resize regardless of subscriber state. The third historical + // value ('phone' = sticky phone-fit after unsubscribe) was removed since + // the toggle UI never produced it and nothing in product depended on it. + // In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() + + // Why: tracks active mobile subscribers per PTY so the runtime can restore + // desktop dimensions on unsubscribe and prevent orphaned overrides during + // rapid tab switches. Keyed by ptyId → inner map of clientId → subscriber. + // The two-level map preserves multi-mobile soundness: phone B subscribing + // does not silently overwrite phone A's record. See + // docs/mobile-presence-lock.md "Multi-mobile subscriber model". + // subscribedAt drives "earliest-by-subscribe-time" restore-target selection + // (only among subscribers with non-null previousCols/Rows; desktop-mode + // joins carry null and are skipped). lastActedAt drives "most-recent + // actor's viewport wins" for active phone-fit dims. + private mobileSubscribers = new Map< + string, + Map< + string, + { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + > + >() + + // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe + // stream feeds a remote xterm view (mobile/web/remote desktop) that answers + // queries with view authority, so main must yield while one is attached + // (terminal-query-authority.md). Ref-counted per PTY because multiple + // streams can attach concurrently; mobileSubscribers is consulted too so + // grace-window mobile records keep suppressing. + private remoteTerminalViewSubscriberCounts = new Map() + // Preview windows consume the raw stream but deliberately leave terminal + // query replies to main's headless emulator. + private rawTerminalViewSubscriberCounts = new Map() + // Why a sticky promise per PTY: the daemon only emits data for sessions this + // app has attached, so the first remote view subscriber of a never-attached + // local daemon session triggers a main-side attach. The map dedupes + // concurrent first-subscribes and keeps later subscribes no-ops; it is + // cleared per lifecycle generation (exit/respawn) and on failed attempts. + private subscriberDrivenProviderAttachesByPtyId = new Map>() + private subscriberDrivenProviderAttachInventoryWaiters = new Set() + // Why: a spawn through this app already attaches its provider stream, so + // subscriber-driven attach and the never-attached read fallback must target + // only inventory-discovered sessions no local spawn published this + // generation (a replacement spawn under a reused id starts clean). + private spawnPublishedPtys = new Set() + + // Why: per-PTY driver state. The "driver" is whoever currently owns the + // input/resize floor. While `kind === 'mobile'` the desktop renderer drops + // xterm.onData/onResize and shows the lock banner; `terminal.send` / + // `pty:write` and `pty:resize` IPC handlers also drop desktop-side calls + // server-side as defense-in-depth. The `clientId` carried on the mobile + // variant is the most recent mobile actor — used by + // `applyMobileDisplayMode` to pick the active phone-fit viewport. See + // docs/mobile-presence-lock.md. + private currentDriver = new Map() + private mobileInputFloorClaims = new Map< + string, + { + base: DriverState + generation: number + committedGeneration: number + pending: Map + } + >() + private currentBrowserDriver = new Map() + + // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by + // subscription, not client, because one client can open duplicate streams and + // each stream must release only the width floor it registered. + private remoteDesktopViewers = new Map< + string, + Map + >() + private remoteDesktopOwners = new Map() + private remoteDesktopActivity = 0 + private remoteDesktopHostReclaimTargets = new Map() + // Why: a completed host reclaim must not consume the cache if a newer + // viewer mutation landed while that serialized layout was in flight. + private remoteDesktopViewerRevisions = new Map() + + // Why: resubscribe-grace window. When the last mobile subscriber for a + // PTY unsubscribes, we hold the driver=mobile{clientId} state and the + // inner-map record open for ~250ms. If the same (ptyId, clientId) + // re-subscribes inside the window — typically because the mobile app + // tore down the stream to reconfigure (rare with the new + // updateMobileViewport path, but still possible on reconnects, network + // hiccups, or older client builds) — we cancel the deferred idle and + // restore-timer so the desktop banner doesn't flash and the new + // subscriber doesn't capture an already-phone-fitted PTY size as its + // restore baseline. Keyed by ptyId; carries the timer plus the snapshot + // of the leaving subscriber so we can re-insert it on cancel. See + // docs/mobile-presence-lock.md. + private pendingSoftLeavers = new Map< + string, + { + clientId: string + timer: ReturnType + record: { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + } + >() + + // Why: tracks the last PTY size set by the desktop renderer (via pty:resize + // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit + // resizes), this map preserves the actual pane geometry. Used as the + // preferred source for previousCols so desktop restore uses the correct + // split-pane width instead of a stale full-width value. + private lastRendererSizes = new Map() + + // Why: when a desktop-fit override change fires, the desktop renderer's + // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL + // panes — not just the affected one. Background tab panes get measured at + // full-width (214) instead of their correct split width (105). The stale + // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. + // This global window suppresses ALL pty:resize for 200ms after any + // desktop-fit notification. The server has already set the correct PTY + // size via ptyController.resize(), so desktop renderer resizes during + // this window are redundant (for the restored pane) or wrong (collateral). + private resizeSuppressedUntil = 0 + + // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab + // switches don't cause unnecessary resize thrashing. Keyed by clientId + // Why: keyed by ptyId so each PTY gets its own independent restore timer. + // The old clientId-keyed design lost timers when two PTYs were unsubscribed + // back-to-back (only the last timer survived). + private pendingRestoreTimers = new Map< + string, + { timer: ReturnType; clientId: string } + >() + + // Why: inline resize events replace the unsubscribe→resubscribe pattern. + // Listeners are notified when mode changes or desktop restores, allowing + // the subscribe stream to emit a 'resized' event with fresh scrollback. + // `seq` is the layout state-machine sequence number bumped on every + // applyLayout success; mobile clients use it to drop stale events that + // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. + private resizeListeners = new Map< + string, + Set< + (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + > + >() + + // Why: per-PTY layout state machine. `applyLayout` is the sole writer of + // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every + // trigger method routes through `enqueueLayout`. The monotonic `seq` is + // emitted on the mobile subscribe stream so clients can drop stale events. + // See docs/mobile-terminal-layout-state-machine.md. + private layouts = new Map() + + // Why: per-PTY async serialization queue for applyLayout. Without + // serialization, two concurrent triggers can interleave around the + // ptyController.resize await and bump seq in the wrong order, defeating + // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the + // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, + // take-floor, and different-owner targets always append (preserves + // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md + // "enqueueLayout coalescing". + private layoutQueues = new Map() + + // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't + // fire on the very first transition for a PTY (where the entry doesn't + // exist yet *because* we're about to create it). `handleMobileSubscribe` + // adds the ptyId before calling enqueueLayout and removes it after the + // call resolves. + private freshSubscribeGuard = new Set() + + private stats: StatsCollector | null = null + // Why (§3.3 + §7.1): the renderer-create path and coordinator + // `probeWorktreeDrift` share this cache so a create that already fetched + // `origin` within the last 30s does not re-fetch during dispatch, and + // vice-versa. Keyed by `::` so multi-remote repos (even + // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map + // also provides serialization — two concurrent callers share a single + // underlying `git fetch`. Full-remote fetch lifecycle rules: + // - entry inserted BEFORE await, + // - `.finally()` removes the entry on BOTH success and rejection, + // - timestamp written ONLY on success (rejection must not make the + // 30s freshness cache lie). + // A literal "insert before await / read-back after await" without these + // three rules wedges future fetches on the same repo after a single + // DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref + // refreshes share the in-flight rule and maintain their own exact-base + // freshness entries; a full-remote fetch may be narrowed by repo refspecs, + // so it must not prove a specific branch for create. + private fetchInflight = new Map>() + // Why: `git fetch origin` and `git fetch origin ` contend for the + // same repo remote/ref locks. This queue serializes all fetch shapes for one + // canonical repo+remote while still letting same-shape callers share promises. + private remoteFetchQueueTail = new Map>() + private fetchLastCompletedAt = new Map() + // Why: `getCanonicalFetchKey` is awaited from every freshness probe and + // every getOrStartRemoteFetch call. Without memoization the warm-cache hot + // path spawns a `git rev-parse --git-common-dir` subprocess per touch + // (twice in createLocalWorktree). Cache by `::` so the + // canonical key is resolved at most once per repo+remote in the process. + private canonicalFetchKeyCache = new Map() + private optimisticReconcileTokens = new Map() + private removeManagedWorktreeInFlight = new Map() + private preservedBranchCleanupByScope = new Map() + private readonly getLocalProviderFn: (() => IPtyProvider) | null + private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null + private readonly onPtyStopped: ((ptyId: string) => void) | null + private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null + private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectLocalConsumerAvailable = false + private terminalSideEffectConsumerAvailable = false + private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionRowsForPaneFn: + | ((paneKey: string) => AgentStatusIpcPayload[]) + | null + private readonly attestAgentHookCompatibilityAuthorityFn: + | ((candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }) => AgentHookAuthorityAttestation | null) + | null + private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + private readonly reconcileAgentStatusForEndedProcessFn: + | ((paneKeys: Iterable) => void) + | null + private readonly canRecoverPersistentLocalPtysFn: () => boolean + private readonly getPairedDeviceNameFn: (pairedDeviceId: string) => string | null + private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus + private readonly prepareAiVaultSessionResumeFn: + | ((args: AiVaultPrepareSessionResumeArgs) => Promise) + | null + private readonly prepareCodexStructuredLaunchFn: + | ((input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise) + | null + private readonly agentSessionClaimSigner: AgentSessionClaimSigner + private readonly agentSessionCreateOperations = new Map() + private readonly orchestrationCompatibilitySshAttachments = new Map< + string, + OrchestrationCompatibilitySshAttachmentAuthority + >() + private sshRelayRecoveryGenerationByTargetId = new Map() + private legacyWorkerTerminalRecoveryQueue: Promise = Promise.resolve() + private legacyWorkerTerminalRecoveryRetries = new Map< + string, + { + attempt: number + connectionId?: string + materializeRenderer: boolean + timer: ReturnType | null + } + >() + private legacyWorkerTerminalReceiptEpochByPane = new Map() + private legacyWorkerRecoveredPtys = new Set() + private restoredOrchestrationAuthorityByPtyId = new Map< + string, + RestoredOrchestrationAuthorityReceipt + >() + private ptyControllerInventorySequence = 0 + private ptyControllerAggregateInventoryGeneration = 0 + private ptyControllerInventoryGenerationByProvider = new Map() + private accountServices: RuntimeAccountServices | null = null + private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null + private automationService: AutomationService | null = null + private artifactService: ArtifactCloudService | null = null + private skillCloudService: SkillCloudService | null = null + private agentSkillShareInProgress = false + private skillUploadSessions: SkillUploadSessionService | null = null + private skillUploadSessionsDisposed = false + private readonly skillTransactionRecovery: Promise + private readonly skillInstallOperations = new Map() + private readonly skillInstallProgress = new Map() + private readonly claudeAgentTeams = new ClaudeAgentTeamsService() + private mobileDictation: { + id: string + owner: string + clientId?: string + connectionId?: string + state: 'starting' | 'active' | 'closing' + partialText: string + finalTexts: string[] + errors: string[] + } | null = null + + constructor( + store: RuntimeStore | null = null, + stats?: StatsCollector, + deps?: { + getLocalProvider?: () => IPtyProvider + getSshProvider?: (connectionId: string) => IPtyProvider | undefined + onPtyStopped?: (ptyId: string) => void + onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void + // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC + // terminal output. worktree.ps reads this at query time so mobile shows the + // same inline agent rows the desktop sidebar does — same source, 1:1. + getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` + * filters out so they can't read as running agents. Mobile native chat needs + * them: for an agent that publishes identity separately (Pi), that row is the + * only carrier of the provider session a transcript is addressed by. */ + getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[] + getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[] + attestAgentHookCompatibilityAuthority?: (candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }) => AgentHookAuthorityAttestation | null + retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + canRecoverPersistentLocalPtys?: () => boolean + // Why: the device registry lives on the RPC server, which is constructed with this runtime; + // a closure defers the lookup past that ordering instead of inverting ownership. + getPairedDeviceName?: (pairedDeviceId: string) => string | null + // Why: codex-home paths for the Agent Session History scan must be sourced + // here, not via the window-only registerCoreHandlers path — that path never + // runs under `orca serve`, so remote/SSH hosts would silently drop + // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. + getAdditionalAiVaultCodexHomePaths?: () => readonly string[] + prepareAiVaultSessionResume?: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise + prepareCodexStructuredLaunch?: (input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise + buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus + agentSessionClaimSigner?: AgentSessionClaimSigner + orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport + skillTransactionRecovery?: Promise + } + ) { + this.store = store + // Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return. + const persistedClientTabSelections = store?.getMobileClientTabSelections?.() + if (persistedClientTabSelections) { + this.clientSessionTabSelections.hydrate(persistedClientTabSelections) + } + this.clientSessionTabSelections.setPersistListener((state) => { + this.store?.setMobileClientTabSelections?.(state) + }) + this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null + this.skillTransactionRecovery = (deps?.skillTransactionRecovery ?? Promise.resolve()).catch( + (error) => { + console.warn('[skills] startup transaction recovery failed:', error) + } + ) + if (stats) { + this.stats = stats + } + this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionSnapshotFn = + deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null + this.attestAgentHookCompatibilityAuthorityFn = + deps?.attestAgentHookCompatibilityAuthority ?? null + this.retireAgentHookCompatibilityAuthorityFn = + deps?.retireAgentHookCompatibilityAuthority ?? null + this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null + this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) + this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) + // Why: configure the shared AiVault scan cache from a serve-mode-reachable + // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions + // even on headless `orca serve` hosts where registerCoreHandlers never runs. + if (deps?.getAdditionalAiVaultCodexHomePaths) { + configureAiVaultSessionSources({ + getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths + }) + configureHostReadableTranscriptPathSources({ + getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths + }) + } + // Why: the daemon adapter is installed via `setLocalPtyProvider()` during + // attachMainWindowServices, AFTER this service is constructed. Capturing + // `getLocalPtyProvider()` at construction time would freeze a reference to + // the pre-daemon `LocalPtyProvider` and miss the routed adapter. Resolve + // lazily via thunk so teardown always sees the currently-installed + // provider (design §4.3 wire-up). + this.getLocalProviderFn = deps?.getLocalProvider ?? null + this.getSshProviderFn = deps?.getSshProvider ?? null + this.onPtyStopped = deps?.onPtyStopped ?? null + this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null + this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') + this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null + this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null + this.agentSessionClaimSigner = + deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) + this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null + // Why: the ConPTY spawn mark can land after daemon stream data already + // created this PTY's emulator; the mark retrofits the DA1 override here + // (terminal-query-authority.md §ConPTY DA1). + registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) + } + + /** + * Republishes persisted client-hosted pages as held rows, before any host can attach. + * + * Without this a runtime restart takes the only record of a client-hosted page with it. When the + * client restarted too -- a fleet update restarts both -- its guests died with it, so its + * inventory has nothing to adopt from and no participant can name the page any more. + * + * Called from each host's startup rather than the constructor so the ordering against attach is + * explicit, and so constructing a runtime stays free of persistence reads. + */ + rehydrateClientHostedBrowserPages(): void { + if (!this.store?.getWorkspaceSession) { + return + } + try { + const registry = getRuntimeBrowserPageRegistry(this) + const liveRepoIds = new Set((this.store.getRepos?.() ?? []).map((repo) => repo.id)) + rehydrateClientHostedBrowserPages(registry, { + listWorkspaceSessions: () => this.listWorkspaceSessionPartitions(), + // Why the same discriminant hydration uses: session keys are `${repoId}::${path}` and are + // not pruned when a repo leaves this client's view, so a row whose repo is gone would + // surface a tab with no live workspace behind it. Unparseable keys are left alone. + isKnownWorktree: (worktreeId) => { + const ownerRepoId = splitWorktreeIdForFilesystem(worktreeId)?.repoId + return !ownerRepoId || liveRepoIds.has(ownerRepoId) + } + }) + for (const page of registry.listPages()) { + this.persistedClientHostedBrowserWorktreeIds.add(page.workspaceId) + } + } catch (error) { + console.warn('[browser-host-lease] client page rehydration failed:', error) + } + } + + /** + * Rewrites one worktree's persisted client-hosted rows. + * + * Guarded because it hangs off the runtime's tab-change announcement, which also fires on + * terminal and editor churn: a workspace that has never had a client page must not pay a session + * read for every one of those. + */ + private persistClientHostedBrowserPagesForWorktree(worktreeId: string): void { + const registry = getRuntimeBrowserPageRegistry(this) + const hasPages = registry.listPages(worktreeId).length > 0 + if (!hasPages && !this.persistedClientHostedBrowserWorktreeIds.has(worktreeId)) { + return + } + if (hasPages) { + this.persistedClientHostedBrowserWorktreeIds.add(worktreeId) + } else { + this.persistedClientHostedBrowserWorktreeIds.delete(worktreeId) + } + persistClientHostedBrowserPages( + { + getWorkspaceSession: (id) => this.getWorkspaceSessionForWorktree(id), + setWorkspaceSession: (id, session) => this.setWorkspaceSessionForWorktree(id, session) + }, + registry, + worktreeId + ) + } + + private listWorkspaceSessionPartitions(): WorkspaceSessionState[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const repo of this.store?.getRepos?.() ?? []) { + hostIds.add(getRepoExecutionHostId(repo)) + } + return [...hostIds].flatMap((hostId) => { + const session = this.store?.getWorkspaceSession?.(hostId) + return session ? [session] : [] + }) + } + + getLocalProvider(): IPtyProvider | null { + return this.getLocalProviderFn ? this.getLocalProviderFn() : null + } + + private async stopPtysForDestructiveWorktreeRemoval( + worktreeId: string, + options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} + ): Promise { + const { connectionId, allowUnverifiedStop } = options + const provider = connectionId ? this.getSshProviderFn?.(connectionId) : this.getLocalProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime: this, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection (mirrors the IPC removal path). + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: provider, + onPtyStopped: this.onPtyStopped ?? undefined, + requirePhysicalStop: true, + // Why (#11960): set only by an explicit Force Delete, never by the ordinary + // confirmation — otherwise the gate would be off on the primary delete path. + ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + + teardownResult.providerStopped + + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } + } + + getStatsSummary(): StatsSummary | null { + return this.stats?.getSummary() ?? null + } + + getMemorySnapshot(): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return collectMemorySnapshot(this.store) + } + + getUIState(): PersistedUIState { + if (!this.store?.getUI) { + throw new Error('runtime_unavailable') + } + return this.store.getUI() + } + + updateUIState(updates: Partial): PersistedUIState { + if (!this.store?.getUI || !this.store.updateUI) { + throw new Error('runtime_unavailable') + } + this.store.updateUI(updates) + return this.store.getUI() + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState { + if (!this.store?.recordFeatureInteraction) { + throw new Error('runtime_unavailable') + } + return this.store.recordFeatureInteraction(id) + } + + getClientSettings(): Pick< + GlobalSettings, + | 'worktreeVisibilityDefaults' + | 'defaultTuiAgent' + | 'disabledTuiAgents' + | 'agentCmdOverrides' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'agentStatusHooksEnabled' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + | 'experimentalNewWorktreeCardStyle' + | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' + | 'prBotAuthorOverrides' + // Read-only on purpose: clients preflight the publish capability here, but SettingsUpdate + // still omits the key so no RPC caller can grant it to itself. + | 'artifactSharingEnabled' + | 'agentSkillSharingEnabled' + > { + if (!this.store?.getSettings) { + throw new Error('runtime_unavailable') + } + const settings = this.store.getSettings() + return { + worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, + defaultTuiAgent: settings.defaultTuiAgent ?? null, + disabledTuiAgents: settings.disabledTuiAgents ?? [], + agentCmdOverrides: settings.agentCmdOverrides ?? {}, + agentDefaultArgs: settings.agentDefaultArgs ?? {}, + agentDefaultEnv: settings.agentDefaultEnv ?? {}, + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + defaultTaskSource: settings.defaultTaskSource ?? 'github', + defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', + visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], + defaultRepoSelection: settings.defaultRepoSelection ?? null, + defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, + githubProjects: settings.githubProjects, + experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, + compactWorktreeCards: settings.compactWorktreeCards === true, + minimaxGroupId: settings.minimaxGroupId ?? '', + minimaxUsageModels: settings.minimaxUsageModels ?? 'general', + prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [], + artifactSharingEnabled: isArtifactSharingEnabled(settings), + agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings) + } + } + + private reconcileManagedAgentHooks(): Promise { + const generation = ++this.managedHookReconciliationGeneration + const reconciliation = this.managedHookReconciliationTail.then(async () => { + if (generation !== this.managedHookReconciliationGeneration) { + return + } + const settings = this.store?.getSettings() + if (!settings) { + return + } + await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { + shouldHydrateShellPath: getAppEnvironment().isPackaged(), + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => { + const current = this.store?.getSettings() + return ( + current !== undefined && + current.agentStatusHooksEnabled !== false && + !current.disabledTuiAgents?.includes(agent) + ) + } + }) + }) + this.managedHookReconciliationTail = reconciliation.catch(() => {}) + return reconciliation + } + + async updateClientSettings( + updates: Pick< + Partial, + | 'worktreeVisibilityDefaults' + | 'agentStatusHooksEnabled' + | 'defaultTuiAgent' + | 'disabledTuiAgents' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + | 'experimentalNewWorktreeCardStyle' + | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' + | 'prBotAuthorOverrides' + > + ): Promise< + Pick< + GlobalSettings, + | 'worktreeVisibilityDefaults' + | 'defaultTuiAgent' + | 'disabledTuiAgents' + | 'agentCmdOverrides' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'agentStatusHooksEnabled' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + | 'experimentalNewWorktreeCardStyle' + | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' + | 'prBotAuthorOverrides' + > + > { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const beforeSettings = this.store.getSettings() + const before = beforeSettings.agentStatusHooksEnabled !== false + this.store.updateSettings(updates, { notifyListeners: true }) + const settings = this.store.getSettings() + if (updates.worktreeVisibilityDefaults !== undefined) { + this.notifyReposChanged() + } + if ( + (typeof updates.agentStatusHooksEnabled === 'boolean' && + before !== updates.agentStatusHooksEnabled) || + (updates.disabledTuiAgents !== undefined && + !haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents)) + ) { + await this.reconcileManagedAgentHooks() + } + return this.getClientSettings() + } + + getClientTerminalQuickCommands(): TerminalQuickCommand[] { + if (!this.store?.getSettings) { + throw new Error('runtime_unavailable') + } + return this.store.getSettings().terminalQuickCommands ?? [] + } + + updateClientTerminalQuickCommands( + mutation: TerminalQuickCommandMutation + ): TerminalQuickCommand[] { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const current = this.getClientTerminalQuickCommands() + if ( + mutation.type === 'upsert' && + !current.some((command) => command.id === mutation.command.id) && + current.length >= MAX_QUICK_COMMANDS + ) { + throw new Error('Quick command limit reached') + } + const next = applyTerminalQuickCommandMutation(current, mutation) + this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true }) + return this.getClientTerminalQuickCommands() + } + + updateClientPRBotAuthorOverride(args: { author: string; isBot: boolean }) { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const current = this.store.getSettings().prBotAuthorOverrides + this.store.updateSettings( + { prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) }, + { notifyListeners: true } + ) + return this.getClientSettings() + } + + listAutomations(): Automation[] { + if (!this.store?.listAutomations) { + throw new Error('runtime_unavailable') + } + return this.store.listAutomations() + } + + // Why: Orca's own automation work holds the desktop probe scheduler's + // priority lease so queued external probes stay parked behind it; runtime + // servers install no lease and run directly. + private underExternalProbePriority(run: () => T): T { + const wrap = this.automationService?.externalProbePriority + return wrap ? wrap(run) : run() + } + + listAutomationsForScope(params: AutomationListParams): AutomationListResult { + const store = this.store + if (!store?.listAutomationsForScope) { + throw new Error('runtime_unavailable') + } + return this.underExternalProbePriority(() => store.listAutomationsForScope!(params)) + } + + // Why: a supplied precondition must never degrade to unfenced work, so a store that cannot check it fails the call. + private fenceAutomationOwner( + id: string, + expectedOwner: AutomationOwnerPrecondition | undefined, + operation: AutomationOwnerFenceOperation + ): void { + if (!this.store?.assertAutomationOwnerFence) { + if (expectedOwner) { + throw new Error('runtime_unavailable') + } + return + } + this.store.assertAutomationOwnerFence({ id, expectedOwner, operation }) + } + + listAutomationRuns( + automationId?: string, + expectedOwner?: AutomationOwnerPrecondition + ): AutomationRun[] { + const store = this.store + if (!store?.listAutomationRuns) { + throw new Error('runtime_unavailable') + } + if (expectedOwner && !automationId) { + throw new Error('An expected owner requires an automation id.') + } + return this.underExternalProbePriority(() => { + if (automationId) { + this.fenceAutomationOwner(automationId, expectedOwner, 'read') + } + return store.listAutomationRuns!(automationId) + }) + } + + /** Null when the store predates the projection: the caller then sends no precondition. */ + automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null { + return this.store?.automationOwnerPrecondition?.(id) ?? null + } + + showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation { + const automation = this.listAutomations().find((entry) => entry.id === id) + if (!automation) { + throw new Error('Automation not found.') + } + this.fenceAutomationOwner(id, expectedOwner, 'read') + return automation + } + + async createAutomation(input: RuntimeAutomationCreateInput): Promise { + if (!this.store?.createAutomation) { + throw new Error('runtime_unavailable') + } + const target = await this.resolveAutomationTarget(input) + assertAutomationRunContextMatchesRepo(input.runContext, target.repo) + if (input.reuseSession && target.workspaceMode !== 'existing') { + throw new Error('Session reuse requires an existing workspace target.') + } + const createInput: AutomationCreateInput = { + creationKey: input.creationKey, + name: input.name, + prompt: input.prompt, + precheck: input.precheck, + agentId: input.agentId, + runContext: input.runContext, + sourceContext: input.sourceContext, + projectId: target.projectId, + workspaceMode: target.workspaceMode, + workspaceId: target.workspaceId, + baseBranch: input.baseBranch, + setupDecision: input.setupDecision, + reuseSession: input.reuseSession, + timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + rrule: input.rrule, + dtstart: input.dtstart, + enabled: input.enabled, + missedRunGraceMinutes: input.missedRunGraceMinutes + } + return this.underExternalProbePriority(() => { + const created = this.store!.createAutomation!( + createInput, + input.destination ? { destination: input.destination } : undefined + ) + const selector = this.automationChangeSelector(created.id) + this.publishAutomationDefinitionChange(selector, selector) + return created + }) + } + + private automationChangeSelector(id: string): AutomationChangeSelector | null { + return this.store?.automationChangeSelector?.(id) ?? null + } + + /** A store that cannot name the affected host degrades to one unscoped authority event. */ + private publishAutomationDefinitionChange( + before: AutomationChangeSelector | null, + after: AutomationChangeSelector | null + ): void { + for (const selector of automationChangePublications(before, after)) { + this.notifyAutomationsChanged({ reason: 'definition', ...(selector ? { selector } : {}) }) + } + } + + async updateAutomation( + id: string, + updates: RuntimeAutomationUpdateInput, + options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination } + ): Promise { + if (!this.store?.updateAutomation) { + throw new Error('runtime_unavailable') + } + const current = this.showAutomation(id) + const patch: AutomationUpdateInput = {} + if (hasRuntimeAutomationUpdateValue(updates, 'name')) { + patch.name = updates.name + } + if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { + patch.prompt = updates.prompt + } + if (hasRuntimeAutomationUpdateValue(updates, 'precheck')) { + patch.precheck = updates.precheck + } + if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { + patch.agentId = updates.agentId + } + if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) { + patch.runContext = updates.runContext + } + if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) { + patch.sourceContext = updates.sourceContext + } + if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { + patch.baseBranch = updates.baseBranch + } + if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) { + patch.setupDecision = updates.setupDecision + } + if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) { + patch.reuseSession = updates.reuseSession + } + if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) { + patch.timezone = updates.timezone + } + if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) { + patch.rrule = updates.rrule + } + if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) { + patch.dtstart = updates.dtstart + } + if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) { + patch.enabled = updates.enabled + } + if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) { + patch.missedRunGraceMinutes = updates.missedRunGraceMinutes + } + const targetChanged = + hasRuntimeAutomationUpdateValue(updates, 'repo') || + hasRuntimeAutomationUpdateValue(updates, 'workspace') || + hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') + if (targetChanged) { + const target = await this.resolveAutomationTarget(updates, current) + assertAutomationRunContextMatchesRepo(updates.runContext, target.repo) + if (patch.reuseSession === true && target.workspaceMode !== 'existing') { + throw new Error('Session reuse requires an existing workspace target.') + } + patch.projectId = target.projectId + patch.workspaceMode = target.workspaceMode + patch.workspaceId = target.workspaceId + if (target.workspaceMode !== 'existing') { + patch.reuseSession = false + } + } else if (hasRuntimeAutomationUpdateValue(updates, 'runContext') && current.projectId) { + const currentRepo = await this.showRepo(`id:${current.projectId}`) + assertAutomationRunContextMatchesRepo(updates.runContext, currentRepo) + } + if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { + throw new Error('Session reuse requires an existing workspace target.') + } + return this.underExternalProbePriority(() => { + // Captured first: an update may move the record to another host. + const before = this.automationChangeSelector(id) + const updated = this.store!.updateAutomation!(id, patch, options) + this.publishAutomationDefinitionChange(before, this.automationChangeSelector(id)) + return updated + }) + } + + deleteAutomation( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ): { removed: boolean; id: string } { + if (!this.store?.deleteAutomation) { + throw new Error('runtime_unavailable') + } + return this.underExternalProbePriority(() => { + this.showAutomation(id) + const before = this.automationChangeSelector(id) + this.store!.deleteAutomation!(id, expectedOwner ? { expectedOwner } : undefined) + this.publishAutomationDefinitionChange(before, before) + return { removed: true, id } + }) + } + + async runAutomationNow( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ): Promise { + const service = this.automationService + if (!service) { + throw new Error('runtime_unavailable') + } + // Why: an orphan or re-registered host must be refused before dispatch, not + // after a session starts. The lease spans the whole dispatch promise, so + // queued external probes stay parked until the run the user is waiting on settles. + return await this.underExternalProbePriority(() => + runAutomationNowFenced({ + fence: () => this.fenceAutomationOwner(id, expectedOwner, 'execute'), + service, + automationId: id + }) + ) + } + + private async resolveAutomationTarget( + input: { + repo?: string + workspace?: string + workspaceMode?: AutomationWorkspaceMode + baseBranch?: string | null + }, + current?: Automation + ): Promise<{ + projectId: string + workspaceMode: AutomationWorkspaceMode + workspaceId?: string | null + repo: Repo | null + }> { + const hasRepo = input.repo !== undefined + const hasWorkspace = input.workspace !== undefined + if ( + current?.workspaceMode === 'existing' && + hasRepo && + !hasWorkspace && + input.workspaceMode !== 'new_per_run' + ) { + throw new Error( + 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' + ) + } + const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null + const repoSelector = + input.repo ?? + (workspace?.repoId + ? `id:${workspace.repoId}` + : current?.projectId + ? `id:${current.projectId}` + : null) + const repo = repoSelector ? await this.showRepo(repoSelector) : null + const workspaceMode = + input.workspaceMode ?? + (workspace + ? 'existing' + : input.repo && !current + ? 'new_per_run' + : (current?.workspaceMode ?? 'new_per_run')) + if (workspaceMode === 'existing') { + const workspaceId = workspace?.id ?? current?.workspaceId + const projectId = workspace?.repoId ?? current?.projectId + if (repo && repo.id !== projectId) { + throw new Error('Selected workspace belongs to a different repo.') + } + if (!workspaceId || !projectId) { + throw new Error('Existing-workspace automation requires --workspace.') + } + return { projectId, workspaceMode, workspaceId, repo } + } + const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId + if (!projectId) { + throw new Error('Automation requires --repo or --workspace.') + } + return { projectId, workspaceMode: 'new_per_run', workspaceId: null, repo } + } + + // Why: lazy initialization — the DB path depends on userData, which on the desktop + // is not finalized until after app.ready. Also allows unit tests to inject an + // in-memory DB without touching the filesystem. + getOrchestrationDb(): OrchestrationDb { + if (!this._orchestrationDb) { + const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') + this._orchestrationDb = new OrchestrationDb(dbPath) + this.ensureOrchestrationFederationRelay() + this.scheduleRestoredMessageRepoints() + } + return this._orchestrationDb + } + + setOrchestrationDb(db: OrchestrationDb): void { + this.stopOrchestrationFederationRelay() + this.mailPointerRepointScheduler.clear() + this._orchestrationDb = db + this.ensureOrchestrationFederationRelay() + this.scheduleRestoredMessageRepoints() + } + + private getLegacyWorkerTerminalRecoveryPlan(): LegacyWorkerTerminalRecoveryPlan { + try { + return planLegacyWorkerTerminalRecovery( + this.getOrchestrationDb().listLegacyWorkerTerminalRecoveryRows() + ) + } catch (error) { + console.warn('[orchestration] failed to plan legacy worker terminal recovery', error) + return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } + } + } + + prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { + const plan = this.getLegacyWorkerTerminalRecoveryPlan() + const store = this.store + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { + return plan + } + const sessions = new Map< + ExecutionHostId, + { current: WorkspaceSessionState; next: WorkspaceSessionState } + >() + const changedHostIds = new Set() + for (const blocked of plan.blockedPanes) { + let hostIds: ExecutionHostId[] + try { + hostIds = [this.getWorkspaceSessionHostIdForWorktree(blocked.worktreeId)] + } catch (error) { + console.warn('[orchestration] legacy worker resume fence owner is unavailable', { + worktreeId: blocked.worktreeId, + error + }) + hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + } + for (const hostId of hostIds) { + let state = sessions.get(hostId) + if (!state) { + const current = store.getWorkspaceSession(hostId) + if (!current) { + continue + } + state = { current, next: structuredClone(current) } + sessions.set(hostId, state) + } + const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] + if ( + !record || + !worktreeIdsEqual(record.worktreeId, blocked.worktreeId) || + record.automaticResumeBlockedBy === 'legacy-orchestration-worker' + ) { + continue + } + state.next.sleepingAgentSessionsByPaneKey = { + ...state.next.sleepingAgentSessionsByPaneKey, + [blocked.paneKey]: { + ...record, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + changedHostIds.add(hostId) + } + } + const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) + if (changed.length === 0) { + return plan + } + try { + for (const [hostId, state] of changed) { + store.setWorkspaceSession(state.next, hostId) + } + } catch (error) { + console.warn('[orchestration] failed to stage legacy worker resume fence', error) + } + return plan + } + + private async flushWorkspaceSessionOrThrowAsync(): Promise { + const store = this.store + if (store?.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return + } + if (store?.flushOrThrow) { + store.flushOrThrow() + return + } + throw new Error('workspace_session_persistence_unavailable') + } + + async reconcileLegacyWorkerTerminals( + options: { connectionId?: string; materializeRenderer?: boolean } = {} + ): Promise { + let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void + let rejectResult!: (error: unknown) => void + const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const run = this.legacyWorkerTerminalRecoveryQueue.then(async () => { + try { + resolveResult(await this.reconcileLegacyWorkerTerminalsNow(options)) + } catch (error) { + rejectResult(error) + } + }) + this.legacyWorkerTerminalRecoveryQueue = run.catch(() => undefined) + return result + } + + async refreshRestoredOrchestrationAuthority(connectionId: string | null = null): Promise { + if (connectionId === null && !this.canRecoverPersistentLocalPtysFn()) { + return + } + const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + [...(await this.getResolvedWorktreeMap()).values()], + null, + undefined, + connectionId + ) + if (!inventory) { + throw new Error('terminal_liveness_unavailable') + } + } + + private hasExactTerminalSurfaceIdentity(expected: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + terminalHandle: string + incarnationId: string + }): boolean { + if (this.graphStatus !== 'ready') { + return false + } + const pty = this.ptysById.get(expected.ptyId) + if ( + !pty?.connected || + pty.incarnationId !== expected.incarnationId || + pty.tabId !== expected.tabId || + pty.paneKey !== makePaneKey(expected.tabId, expected.leafId) || + !worktreeIdsEqual(pty.worktreeId, expected.worktreeId) || + this.handleByPtyId.get(expected.ptyId) !== expected.terminalHandle + ) { + return false + } + const tab = this.tabs.get(expected.tabId) + const leaf = this.leaves.get(this.getLeafKey(expected.tabId, expected.leafId)) + const ptyLeaves = this.getLeavesForPty(expected.ptyId) + return ( + Boolean(tab && worktreeIdsEqual(tab.worktreeId, expected.worktreeId)) && + Boolean( + leaf && + leaf.ptyId === expected.ptyId && + worktreeIdsEqual(leaf.worktreeId, expected.worktreeId) + ) && + ptyLeaves.length === 1 && + ptyLeaves[0]?.tabId === expected.tabId && + ptyLeaves[0]?.leafId === expected.leafId + ) + } + + private hasExactPersistedTerminalSurfaceIdentity(expected: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId: string + }): boolean { + const session = this.getWorkspaceSessionForWorktree(expected.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, expected.worktreeId) + : null + if (!session || !sessionWorktreeId) { + return false + } + const tab = session.tabsByWorktree[sessionWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId + ) + const paneKey = makePaneKey(expected.tabId, expected.leafId) + return Boolean( + tab && + session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] === + expected.ptyId && + session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId + ) + } + + private async persistLegacyWorkerTerminalRecoveryBatch( + resolutions: readonly LegacyWorkerTerminalRecoveryResolution[] + ): Promise> { + const store = this.store + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { + return new Set() + } + const originalSessions = new Map() + const stagedSessions = new Map() + const stagedDispatchIds = new Set() + try { + for (const { candidate, resolution } of resolutions) { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(candidate.worktreeId) + const session = hostId ? store.getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originalSessions.set(hostId, originalSessions.get(hostId) ?? session) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && worktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleepingAgentSessionsByPaneKey = { ...next.sleepingAgentSessionsByPaneKey } + delete sleepingAgentSessionsByPaneKey[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey } + } + if (next !== session) { + store.setWorkspaceSession(next, hostId) + } + stagedSessions.set(hostId, store.getWorkspaceSession(hostId)) + stagedDispatchIds.add(candidate.dispatchId) + } + if (stagedDispatchIds.size > 0) { + await this.flushWorkspaceSessionOrThrowAsync() + } + return stagedDispatchIds + } catch (error) { + for (const [hostId, original] of originalSessions) { + const staged = stagedSessions.get(hostId) + const current = store.getWorkspaceSession(hostId) + if (!staged || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(original, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + console.warn('[orchestration] failed to persist legacy worker recovery batch', { + dispatchIds: [...stagedDispatchIds], + error + }) + return new Set() + } + } + + private reconcileMissingLegacyWorkerTerminal( + candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] + ): boolean { + if (candidate.dispatchStatus !== 'pending' && candidate.dispatchStatus !== 'dispatched') { + return true + } + try { + this.getOrchestrationDb().reconcileMissingWorkerTerminal( + candidate.dispatchId, + 'The assigned worker terminal is no longer live after orchestration recovery.' + ) + return true + } catch (error) { + console.warn('[orchestration] failed to reconcile missing worker terminal', { + dispatchId: candidate.dispatchId, + error + }) + return false + } + } + + private rollbackLegacyWorkerTerminalSurface( + candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId) + if (snapshot) { + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId: candidate.ptyId, + exactSurfaces: [{ parentTabId: candidate.tabId, leafId: candidate.leafId }], + exactOnly: true + }) + if (retired) { + this.mobileSessionTabsByWorktree.set(candidate.worktreeId, retired.snapshot) + this.notifyMobileSessionTabsChanged(candidate.worktreeId) + } + } + + const leafKey = this.getLeafKey(candidate.tabId, candidate.leafId) + const leaf = this.leaves.get(leafKey) + const pty = this.ptysById.get(candidate.ptyId) + if ( + leaf?.ptyId === candidate.ptyId && + worktreeIdsEqual(leaf.worktreeId, candidate.worktreeId) + ) { + this.leaves.delete(leafKey) + const surfaceHandle = this.handleByLeafKey.get(leafKey) + this.handleByLeafKey.delete(leafKey) + const handleRecord = surfaceHandle ? this.handles.get(surfaceHandle) : undefined + if ( + surfaceHandle && + handleRecord?.tabId === candidate.tabId && + handleRecord.leafId === candidate.leafId && + handleRecord.ptyId === candidate.ptyId + ) { + this.handles.delete(surfaceHandle) + } + this.rebuildLeafPtyIndex() + if (![...this.leaves.values()].some((entry) => entry.tabId === candidate.tabId)) { + this.tabs.delete(candidate.tabId) + } + } + if (pty?.tabId === candidate.tabId) { + pty.tabId = null + pty.paneKey = null + } + this.notifier?.resolveLegacyWorkerTerminalRecovery?.( + candidate.paneKey, + 'rolled_back', + candidate.ptyId + ) + } + + private updateLegacyWorkerTerminalRecoveryRetry( + plan: LegacyWorkerTerminalRecoveryPlan, + deferredDispatchIds: ReadonlySet, + options: { connectionId?: string; materializeRenderer?: boolean } + ): void { + const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' + const hasDeferredWorker = plan.candidates.some((candidate) => { + const sshPty = parseAppSshPtyId(candidate.ptyId) + const inScope = options.connectionId + ? sshPty?.connectionId === options.connectionId + : sshPty === null + return inScope && deferredDispatchIds.has(candidate.dispatchId) + }) + if (!hasDeferredWorker) { + this.cancelLegacyWorkerTerminalRecoveryRetry(scopeKey) + return + } + const existing = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) + const retry = existing ?? { + attempt: 0, + ...(options.connectionId ? { connectionId: options.connectionId } : {}), + materializeRenderer: options.materializeRenderer === true, + timer: null + } + retry.materializeRenderer ||= options.materializeRenderer === true + this.legacyWorkerTerminalRecoveryRetries.set(scopeKey, retry) + this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) + } + + private cancelLegacyWorkerTerminalRecoveryRetry(scopeKey: string): void { + const retry = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) + if (retry?.timer) { + clearTimeout(retry.timer) + } + this.legacyWorkerTerminalRecoveryRetries.delete(scopeKey) + } + + private armLegacyWorkerTerminalRecoveryRetry( + scopeKey: string, + retry: { + attempt: number + connectionId?: string + materializeRenderer: boolean + timer: ReturnType | null + } + ): void { + if (retry.timer) { + return + } + const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) + retry.attempt += 1 + retry.timer = setTimeout(() => { + retry.timer = null + void this.reconcileLegacyWorkerTerminals({ + ...(retry.connectionId ? { connectionId: retry.connectionId } : {}), + materializeRenderer: retry.materializeRenderer + }).catch((error) => { + console.warn('[orchestration] worker terminal recovery retry failed', { + scope: scopeKey, + error + }) + if (this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) === retry) { + this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) + } + }) + }, delayMs) + retry.timer.unref?.() + } + + private async reconcileLegacyWorkerTerminalsNow(options: { + connectionId?: string + materializeRenderer?: boolean + }): Promise { + const plan = this.prepareLegacyWorkerTerminalRecovery() + const adoptedDispatchIds: string[] = [] + const exitedDispatchIds: string[] = [] + const deferredDispatchIds = new Set(plan.ambiguousDispatchIds) + const pendingResolutions: LegacyWorkerTerminalRecoveryResolution[] = [] + const recoveryCandidatesByProvider = new Map< + string, + { + connectionId: string | null + entries: { + candidate: (typeof plan.candidates)[number] + workspace: TerminalWorkspaceLaunchScope + resolvedWorkspace: ResolvedWorktree + }[] + } + >() + for (const candidate of plan.candidates) { + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${candidate.worktreeId}` + ) + const sshPty = parseAppSshPtyId(candidate.ptyId) + if (workspace.connectionId) { + if ( + options.connectionId !== workspace.connectionId || + sshPty?.connectionId !== workspace.connectionId + ) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + } else if ( + options.connectionId !== undefined || + sshPty !== null || + !this.canRecoverPersistentLocalPtysFn() + ) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + const resolvedWorkspace = workspace.folderWorkspace + ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) + : await this.resolveWorktreeSelector(`id:${workspace.id}`) + const connectionId = workspace.connectionId ?? null + const providerKey = connectionId === null ? 'local' : `ssh:${connectionId}` + const provider = recoveryCandidatesByProvider.get(providerKey) ?? { + connectionId, + entries: [] + } + provider.entries.push({ candidate, workspace, resolvedWorkspace }) + recoveryCandidatesByProvider.set(providerKey, provider) + } catch { + deferredDispatchIds.add(candidate.dispatchId) + } + } + for (const provider of recoveryCandidatesByProvider.values()) { + const resolvedWorktrees = [ + ...new Map( + provider.entries.map(({ resolvedWorkspace }) => [resolvedWorkspace.id, resolvedWorkspace]) + ).values() + ] + const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + null, + undefined, + provider.connectionId + ) + if (!inventory) { + provider.entries.forEach(({ candidate }) => deferredDispatchIds.add(candidate.dispatchId)) + continue + } + for (const { candidate, workspace } of provider.entries) { + if (!inventory.livePtyIds.has(candidate.ptyId)) { + pendingResolutions.push({ candidate, resolution: 'exited' }) + continue + } + const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId) + if (!controllerIdentity) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if ( + controllerIdentity.handle !== candidate.terminalHandle || + controllerIdentity.incarnationId !== candidate.incarnationId + ) { + pendingResolutions.push({ candidate, resolution: 'exited' }) + continue + } + let adoptionStatus: 'ready' | 'unverifiable' | 'exited' + try { + adoptionStatus = await this.runWorktreeTerminalMutation( + candidate.worktreeId, + async () => { + const preAdoptionInventory = + await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + null, + undefined, + provider.connectionId + ) + if (!preAdoptionInventory) { + return 'unverifiable' + } + if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) { + return 'exited' + } + const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get( + candidate.ptyId + ) + if (!preAdoptionIdentity) { + return 'unverifiable' + } + if ( + preAdoptionIdentity.handle !== candidate.terminalHandle || + preAdoptionIdentity.incarnationId !== candidate.incarnationId + ) { + return 'exited' + } + const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, candidate.worktreeId) + : null + const activeTabId = sessionWorktreeId + ? session?.activeTabIdByWorktree?.[sessionWorktreeId] + : undefined + const activeGroupId = sessionWorktreeId + ? session?.activeGroupIdByWorktree?.[sessionWorktreeId] + : undefined + const exactSurfaceAlreadyPublished = + this.hasExactPersistedTerminalSurfaceIdentity(candidate) && + this.hasExactTerminalSurfaceIdentity(candidate) + if (!exactSurfaceAlreadyPublished) { + await this.adoptTerminalOrphansFromInventoryUnderMutation( + { + worktree: `id:${candidate.worktreeId}`, + expectedTopologyRevision: this.getTerminalTopologyRevision( + candidate.worktreeId + ), + ...(activeTabId ? { activeTabId } : {}), + ...(activeGroupId ? { activeGroupId } : {}), + claims: [ + { + terminal: candidate.terminalHandle, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId, + tabId: candidate.tabId, + leafId: candidate.leafId + } + ] + }, + workspace, + preAdoptionInventory + ) + } + return 'ready' + } + ) + } catch (error) { + console.warn('[orchestration] legacy worker terminal adoption deferred', { + dispatchId: candidate.dispatchId, + error + }) + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (adoptionStatus === 'unverifiable') { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (adoptionStatus === 'exited') { + pendingResolutions.push({ candidate, resolution: 'exited' }) + continue + } + let rendererMaterialized = + options.materializeRenderer !== true || + this.legacyWorkerTerminalReceiptEpochByPane.get(candidate.paneKey) === + this.rendererGraphEpoch + const pty = this.ptysById.get(candidate.ptyId) + if ( + options.materializeRenderer && + !rendererMaterialized && + pty && + this.notifier?.revealTerminalSession + ) { + for (let attempt = 0; attempt < 2 && !rendererMaterialized; attempt += 1) { + try { + const reveal = await this.notifier.revealTerminalSession(candidate.worktreeId, { + ptyId: candidate.ptyId, + title: getLatestPtyTitle(pty) ?? pty.controllerTitle, + activate: false, + presentation: 'background', + tabId: candidate.tabId, + leafId: candidate.leafId, + focus: false, + expectedProcessIdentity: { + terminalHandle: candidate.terminalHandle, + incarnationId: candidate.incarnationId + } + }) + const identity = reveal?.identity + if ( + !identity || + !worktreeIdsEqual(identity.worktreeId, candidate.worktreeId) || + identity.tabId !== candidate.tabId || + identity.leafId !== candidate.leafId || + identity.ptyId !== candidate.ptyId + ) { + throw new Error('terminal_reveal_identity_mismatch') + } + rendererMaterialized = true + this.legacyWorkerTerminalReceiptEpochByPane.set( + candidate.paneKey, + this.rendererGraphEpoch + ) + } catch (error) { + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 100)) + continue + } + console.warn('[orchestration] adopted legacy worker was not revealed', { + dispatchId: candidate.dispatchId, + error + }) + } + } + } + if (!rendererMaterialized) { + this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if ( + options.materializeRenderer === true && + !this.hasExactTerminalSurfaceIdentity({ + worktreeId: candidate.worktreeId, + tabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + terminalHandle: candidate.terminalHandle, + incarnationId: candidate.incarnationId + }) + ) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + const finalInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + null, + undefined, + provider.connectionId + ) + if (!finalInventory) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (!finalInventory.livePtyIds.has(candidate.ptyId)) { + this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) + this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId) + pendingResolutions.push({ candidate, resolution: 'exited' }) + continue + } + const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId) + if (!finalIdentity) { + this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if ( + finalIdentity.handle !== candidate.terminalHandle || + finalIdentity.incarnationId !== candidate.incarnationId + ) { + this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) + pendingResolutions.push({ candidate, resolution: 'exited' }) + continue + } + pendingResolutions.push({ candidate, resolution: 'adopted' }) + } + } + const persistedDispatchIds = + await this.persistLegacyWorkerTerminalRecoveryBatch(pendingResolutions) + for (const { candidate, resolution } of pendingResolutions) { + if (!persistedDispatchIds.has(candidate.dispatchId)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (resolution === 'adopted') { + this.legacyWorkerRecoveredPtys.add(candidate.ptyId) + this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted') + adoptedDispatchIds.push(candidate.dispatchId) + continue + } + this.rollbackLegacyWorkerTerminalSurface(candidate) + if (!this.reconcileMissingLegacyWorkerTerminal(candidate)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') + exitedDispatchIds.push(candidate.dispatchId) + } + const result = { + blockedPaneCount: plan.blockedPanes.length, + adoptedDispatchIds, + exitedDispatchIds, + deferredDispatchIds: [...deferredDispatchIds] + } + this.updateLegacyWorkerTerminalRecoveryRetry(plan, deferredDispatchIds, options) + // Why: previously requested releases may only finish after the owning provider's terminals + // are rediscovered; this pass runs per scope (local and each reconnected provider). + void reconcileRequestedWorkerTerminalReleases(this).catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) + return result + } + + setAutomationService(service: AutomationService): void { + this.automationService = service + } + + setArtifactService(service: ArtifactCloudService): void { + this.artifactService = service + } + + setSkillCloudService(service: SkillCloudService): void { + this.skillCloudService = service + } + + assertAgentSkillSharingAllowed(): void { + assertAgentSkillSharingAllowed(() => isAgentSkillSharingEnabled(this.store?.getSettings())) + } + + /** Renderer-owned; read here because dispatch enforcement lives in main. */ + getNestedWorkerMaxDepth(): number { + return resolveNestedWorkerMaxDepth(this.store?.getSettings()) + } + + async publishDiscoveredSkillsFromAgent( + request: AgentSkillShareRequest, + discoveredSkills: readonly DiscoveredSkill[], + signal?: AbortSignal + ): Promise { + this.assertAgentSkillSharingAllowed() + if (this.agentSkillShareInProgress) { + throw new AgentSkillSharingError( + AGENT_SKILL_SHARING_BUSY_CODE, + 'Another agent skill bundle is being published. Wait for it to finish and try again.' + ) + } + this.agentSkillShareInProgress = true + try { + return await this.executeAgentSkillShare(request, discoveredSkills, signal) + } finally { + this.agentSkillShareInProgress = false + } + } + + private async executeAgentSkillShare( + request: AgentSkillShareRequest, + discoveredSkills: readonly DiscoveredSkill[], + signal?: AbortSignal + ): Promise { + const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors) + const operationRoot = join( + getAppEnvironment().getPath('userData'), + 'agent-skill-share-operations' + ) + const cloud = this.requireSkillCloudService() + const preparations = new SkillSharePreparationService( + operationRoot, + { + publishVersion: (input) => cloud.publishVersion(input), + createShare: (packageId, input) => cloud.createShare(packageId, input) + }, + { + installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs') + } + ) + let preparationId: string | null = null + const cancel = (): void => { + if (preparationId) { + preparations.cancel(preparationId) + } + } + signal?.addEventListener('abort', cancel, { once: true }) + try { + if (signal?.aborted) { + throw signal.reason ?? new Error('skill-share-cancelled') + } + const preview = await preparations + .prepare({ + sources: selectedSkills.map((skill) => ({ + id: skill.name, + sourceDirectory: skill.directoryPath + })), + bundleName: request.bundleName, + description: + selectedSkills.length === 1 + ? (selectedSkills[0].description ?? '') + : `${selectedSkills.length} shared skills` + }) + .catch((error: unknown) => { + if ( + error instanceof Error && + ['skill-package-skill-name-required', 'skill-package-skill-name-invalid'].includes( + error.message + ) + ) { + throw new AgentSkillSharingError( + AGENT_SKILL_NOT_SHAREABLE_CODE, + 'A selected skill cannot be shared. Its SKILL.md must declare a lowercase name containing only letters, numbers, and hyphens.' + ) + } + throw error + }) + preparationId = preview.preparationId + if (signal?.aborted) { + throw signal.reason ?? new Error('skill-share-cancelled') + } + this.assertAgentSkillSharingAllowed() + const published = await preparations.publish({ + preparationId, + releaseNotes: request.releaseNotes + }) + return published.status === 'ok' + ? { + status: 'ok', + value: { + ...published.value, + selectedSkills: selectedSkills.map(({ id, name, description }) => ({ + id, + name, + description + })) + } + } + : published + } finally { + signal?.removeEventListener('abort', cancel) + await preparations.dispose() + } + } + + publishSkillPackage( + request: SkillCloudPublishRequest + ): Promise> { + return this.requireSkillCloudService().publish(request) + } + + publishSkillPackageVersion( + request: SkillCloudPublishRequest + ): Promise> { + return this.requireSkillCloudService().publishVersion(request) + } + + createSkillPackageShare( + packageId: string, + request: SkillCloudOptions & { + pinnedVersionId?: string + idempotencyKey?: string + } + ) { + return this.requireSkillCloudService().createShare(packageId, request) + } + + resolveSkillShare( + shareId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().resolveShare(shareId, options) + } + + createSkillDownloadGrant( + shareId: string, + options: SkillCloudOptions & { + versionId?: string + installTarget?: 'local' | 'remote' + } + ): Promise> { + return this.requireSkillCloudService().createDownloadGrant(shareId, options) + } + + createSkillPackageVersionDownloadGrant( + packageId: string, + versionId: string, + options: SkillCloudOptions & { installTarget?: 'local' | 'remote' } + ): Promise> { + return this.requireSkillCloudService().createPackageVersionDownloadGrant( + packageId, + versionId, + options + ) + } + + getSkillPackage( + packageId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().getPackage(packageId, options) + } + + listOwnedSkillShares(options: SkillCloudOptions) { + return this.requireSkillCloudService().listOwnedShares(options) + } + + revokeSkillShare( + shareId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().revokeShare(shareId, options) + } + + deleteSkillPackageVersion( + packageId: string, + versionId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().deleteVersion(packageId, versionId, options) + } + + deleteSkillPackage( + packageId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().deletePackage(packageId, options) + } + + async installSharedSkillRequest( + request: SkillInstallRequest, + signal?: AbortSignal + ): Promise { + if (this.skillInstallOperations.has(request.operationId)) { + throw new Error('skill-install-operation-in-progress') + } + const controller = new AbortController() + const abort = (): void => controller.abort() + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + this.skillInstallOperations.set(request.operationId, controller) + try { + return await this.executeSharedSkillInstall(request, controller.signal) + } finally { + signal?.removeEventListener('abort', abort) + if (this.skillInstallOperations.get(request.operationId) === controller) { + this.skillInstallOperations.delete(request.operationId) + } + } + } + + async installSharedSkillBundleRequest( + request: SkillBundleInstallRequest, + signal?: AbortSignal, + onProgress?: (progress: SkillBundleInstallProgress) => void + ): Promise { + if (this.skillInstallOperations.has(request.operationId)) { + throw new Error('skill-install-operation-in-progress') + } + const controller = new AbortController() + const abort = (): void => controller.abort() + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + this.skillInstallOperations.set(request.operationId, controller) + const reportProgress = (progress: SkillBundleInstallProgress): void => { + this.skillInstallProgress.set(request.operationId, progress) + try { + onProgress?.(progress) + } catch { + // Why: renderer teardown must not change the host-owned install outcome. + } + } + try { + const runtimeId = this.getStatus().runtimeId + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return installSkillBundleOnSshHost({ + provider: sshTarget.provider, + userDataPath: getAppEnvironment().getPath('userData'), + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace, + requireHttps: getAppEnvironment().isPackaged(), + signal: controller.signal, + onProgress: reportProgress + }) + } + await this.skillTransactionRecovery + const allowedDownloadOrigins = ['https://storage.googleapis.com'] + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + allowedDownloadOrigins.push( + ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + ) + } + return await executeSkillBundleInstallRequest(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: getAppEnvironment().getPath('userData'), + allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], + requireHttps: getAppEnvironment().isPackaged(), + resolveStagedUpload: (uploadId, identity) => + this.requireSkillUploadSessions().take(uploadId, identity), + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination), + signal: controller.signal, + onProgress: reportProgress + }) + } finally { + signal?.removeEventListener('abort', abort) + if (this.skillInstallOperations.get(request.operationId) === controller) { + this.skillInstallOperations.delete(request.operationId) + } + this.skillInstallProgress.delete(request.operationId) + } + } + + getSharedSkillInstallProgress(operationId: string): SkillBundleInstallProgress | null { + return this.skillInstallProgress.get(operationId) ?? null + } + + cancelSharedSkillInstall(operationId: string): boolean { + const operation = this.skillInstallOperations.get(operationId) + operation?.abort() + return Boolean(operation) + } + + private async executeSharedSkillInstall( + request: SkillInstallRequest, + signal: AbortSignal + ): Promise { + const runtimeId = this.getStatus().runtimeId + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return installSkillOnSshHost({ + provider: sshTarget.provider, + userDataPath: getAppEnvironment().getPath('userData'), + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace, + requireHttps: getAppEnvironment().isPackaged(), + signal + }) + } + await this.skillTransactionRecovery + const allowedDownloadOrigins = ['https://storage.googleapis.com'] + if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + allowedDownloadOrigins.push( + ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + ) + } + return executeSkillInstallRequest(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: getAppEnvironment().getPath('userData'), + allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], + requireHttps: getAppEnvironment().isPackaged(), + resolveStagedUpload: (uploadId, identity) => + this.requireSkillUploadSessions().take(uploadId, identity), + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination), + signal + }) + } + + async previewSharedSkillInstallRequest( + request: SkillInstallPreviewRequest + ): Promise { + const runtimeId = this.getStatus().runtimeId + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return previewSkillInstallOnSshHost({ + provider: sshTarget.provider, + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace + }) + } + await this.skillTransactionRecovery + return previewSharedSkillInstall(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: getAppEnvironment().getPath('userData'), + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) + }) + } + + async previewSharedSkillBundleInstallRequest( + request: SkillBundleInstallPreviewRequest + ): Promise { + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return previewSkillBundleInstallOnSshHost({ + provider: sshTarget.provider, + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace + }) + } + await this.skillTransactionRecovery + const runtimeId = this.getStatus().runtimeId + return previewSharedSkillBundleInstall(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: getAppEnvironment().getPath('userData'), + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) + }) + } + + async removeSharedSkillInstallRequest(request: SkillRemoveRequest): Promise { + const runtimeId = this.getStatus().runtimeId + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return removeSkillInstallOnSshHost({ + provider: sshTarget.provider, + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace + }) + } + await this.skillTransactionRecovery + return removeSharedSkillInstall(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: getAppEnvironment().getPath('userData'), + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) + }) + } + + async listManagedSkillInstalls(connectionId?: string): Promise { + if (connectionId) { + const provider = this.requireSkillSshProvider(connectionId) + return listSkillInstallsOnSshHost({ + provider, + connectionId, + workspaces: await this.listSkillSshWorkspaces(connectionId) + }) + } + await this.skillTransactionRecovery + const runtimeId = this.getStatus().runtimeId + const [installs, worktrees] = await Promise.all([ + listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), { + observeReceipt: async (receipt) => { + if (!receipt.wslDistro) { + return nativeSkillInstallFilesystem.observeSkill( + receipt.canonicalPath, + receipt.fileModes + ) + } + const filesystem = new WslSkillInstallFilesystem(receipt.wslDistro, [ + dirname(receipt.canonicalPath) + ]) + return filesystem.observeSkill(receipt.canonicalPath, receipt.fileModes) + } + }), + this.listResolvedWorktrees() + ]) + const folderWorkspaces = this.listFolderWorkspaces() + return installs.flatMap((install): ManagedSkillInstall[] => { + if (install.scope === 'global') { + const wslPrefix = `global:${runtimeId}:wsl:` + return [ + { + ...install, + destination: install.destinationIdentity.startsWith(wslPrefix) + ? { + scope: 'global', + executionTarget: { + kind: 'wsl', + distro: install.destinationIdentity.slice(wslPrefix.length) + } + } + : { scope: 'global' } + } + ] + } + const worktree = worktrees.find( + (candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}` + ) + if (worktree) { + return [{ ...install, destination: { scope: 'workspace', worktreeId: worktree.id } }] + } + const folder = folderWorkspaces.find( + (candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}` + ) + return folder + ? [{ ...install, destination: { scope: 'workspace', folderWorkspaceId: folder.id } }] + : [] + }) + } + + async skillInstallDestinationUsesSsh( + destination: SkillInstallRequest['destination'] + ): Promise { + return Boolean(await this.resolveSkillSshTarget(destination)) + } + + async resolveSkillDiscoveryProviderRoots(target: { + kind: 'native-host' | 'wsl' + distro?: string + }): Promise { + const roots = await this.resolveSkillProviderRootOverrides({ + scope: 'global', + homeDirectory: homedir(), + ...(target.kind === 'wsl' && target.distro ? { wslDistro: target.distro } : {}) + }) + if (target.kind !== 'wsl') { + return roots + } + return Object.fromEntries( + Object.entries(roots).map(([provider, root]) => [provider, toLinuxPath(root)]) + ) + } + + private async resolveSkillProviderRootOverrides(destination: { + scope: 'global' | 'workspace' + homeDirectory: string + workspaceDirectory?: string + wslDistro?: string + }): Promise { + if (destination.scope !== 'global') { + return {} + } + const wslGrokRoot = destination.wslDistro + ? await resolveWslGrokSkillProviderRoot(destination.wslDistro) + : null + const roots: SkillProviderRootOverrides = destination.wslDistro + ? wslGrokRoot + ? { grok: wslGrokRoot } + : {} + : resolveEnvironmentSkillProviderRoots() + const claudeConfigDirectory = this.accountServices?.claudeAccounts.getRuntimeConfigDir( + destination.wslDistro + ? { runtime: 'wsl', wslDistro: destination.wslDistro } + : { runtime: 'host' } + ) + return withClaudeSkillProviderRoot(roots, claudeConfigDirectory) + } + + private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority { + return { + environmentId: runtimeId, + homeDirectory: homedir(), + resolveWorktree: async (id) => { + const repo = this.listRepos().find( + (candidate) => candidate.id === getRepoIdFromWorktreeId(id) + ) + if (repo?.connectionId) { + throw new Error('skill-install-ssh-dispatch-required') + } + const projectRuntime = this.resolveProjectRuntimeForWorktree(id) + const worktree = await this.showManagedWorktree(`id:${id}`) + if (worktree.id !== id) { + return null + } + return { + id, + path: worktree.path, + ...(projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' + ? { wslDistro: projectRuntime.runtime.distro } + : {}) + } + }, + resolveFolderWorkspace: async (id) => { + const workspace = this.listFolderWorkspaces().find((candidate) => candidate.id === id) + if (!workspace || workspace.connectionId) { + return null + } + return { + id, + path: workspace.folderPath, + ...(parseWslUncPath(workspace.folderPath)?.distro + ? { wslDistro: parseWslUncPath(workspace.folderPath)!.distro } + : {}) + } + }, + resolveWsl: async (distro) => { + if (process.platform !== 'win32') { + return null + } + const homeDirectory = getWslHome(distro) + return homeDirectory ? { homeDirectory } : null + } + } + } + + private async resolveSkillSshTarget(destination: SkillInstallRequest['destination']): Promise<{ + provider: () => IPtyProvider + workspace?: SkillSshWorkspaceAuthority + } | null> { + if (destination.scope === 'global') { + if (destination.executionTarget?.kind !== 'ssh') { + return null + } + const connectionId = destination.executionTarget.connectionId + return { provider: () => this.requireSkillSshProvider(connectionId) } + } + if (destination.worktreeId) { + const repo = this.listRepos().find( + (candidate) => candidate.id === getRepoIdFromWorktreeId(destination.worktreeId!) + ) + if (!repo?.connectionId) { + return null + } + const worktree = await this.showManagedWorktree(`id:${destination.worktreeId}`) + if (worktree.id !== destination.worktreeId) { + throw new Error('skill-install-workspace-not-found') + } + return { + provider: () => this.requireSkillSshProvider(repo.connectionId!), + workspace: { kind: 'worktree', id: worktree.id, path: worktree.path } + } + } + const folder = this.listFolderWorkspaces().find( + (candidate) => candidate.id === destination.folderWorkspaceId + ) + if (!folder?.connectionId) { + return null + } + return { + provider: () => this.requireSkillSshProvider(folder.connectionId!), + workspace: { kind: 'folder', id: folder.id, path: folder.folderPath } + } + } + + private requireSkillSshProvider(connectionId: string): IPtyProvider { + const provider = this.getSshProviderFn?.(connectionId) + if (!provider?.requestHostRpc) { + throw new Error('skill-install-ssh-relay-unavailable') + } + return provider + } + + private async listSkillSshWorkspaces( + connectionId: string + ): Promise { + const repos = new Map( + this.listRepos() + .filter((repo) => repo.connectionId === connectionId) + .map((repo) => [repo.id, repo]) + ) + const worktrees = (await this.listResolvedWorktrees()) + .filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id))) + .map((worktree): SkillSshWorkspaceAuthority => ({ + kind: 'worktree', + id: worktree.id, + path: worktree.path + })) + const folders = this.listFolderWorkspaces() + .filter((folder) => folder.connectionId === connectionId) + .map((folder): SkillSshWorkspaceAuthority => ({ + kind: 'folder', + id: folder.id, + path: folder.folderPath + })) + return [...worktrees, ...folders] + } + + beginSkillUpload(request: SkillUploadBeginRequest): Promise<{ + uploadId: string + chunkBytes: number + acknowledgedOffset: number + }> { + return this.requireSkillUploadSessions().begin(request) + } + + appendSkillUploadChunk( + request: SkillUploadChunkRequest + ): Promise<{ acknowledgedOffset: number }> { + return this.requireSkillUploadSessions().append(request) + } + + commitSkillUpload(uploadId: string): Promise<{ uploadId: string }> { + return this.requireSkillUploadSessions().commit(uploadId) + } + + cancelSkillUpload(uploadId: string): Promise { + return this.requireSkillUploadSessions().cancel(uploadId) + } + + listArtifacts(options: ArtifactListOptions): Promise> { + return this.requireArtifactService().list(options) + } + + getPublishedArtifactLink( + request: ArtifactCloudOptions & { sourceKey: string } + ): Promise> { + return this.requireArtifactService().getPublishedLink(request) + } + + shareArtifact(request: ArtifactWriteRequest): Promise> { + return this.requireArtifactService().share(request) + } + + publishArtifact( + request: ArtifactWriteRequest + ): Promise> { + return this.requireArtifactService().publish(request) + } + + updateArtifact(request: ArtifactWriteRequest): Promise> { + return this.requireArtifactService().update(request) + } + + unshareArtifact( + request: ArtifactCloudOptions & { sourceKey: string } + ): Promise> { + return this.requireArtifactService().unshare(request) + } + + deleteArtifact(id: string, options: ArtifactCloudOptions): Promise> { + return this.requireArtifactService().delete(id, options) + } + + private requireArtifactService(): ArtifactCloudService { + if (!this.artifactService) { + throw new Error('Artifact service is unavailable.') + } + return this.artifactService + } + + private requireSkillCloudService(): SkillCloudService { + if (!this.skillCloudService) { + throw new Error('Skill Cloud service is unavailable.') + } + return this.skillCloudService + } + + private requireSkillUploadSessions(): SkillUploadSessionService { + if (this.skillUploadSessionsDisposed) { + throw new Error('skill-upload-service-disposed') + } + this.skillUploadSessions ??= new SkillUploadSessionService( + join( + getAppEnvironment().getPath('userData'), + 'skill-installs', + SKILL_UPLOAD_STAGING_ROOT_NAME + ) + ) + return this.skillUploadSessions + } + + async disposeSkillUploadSessions(): Promise { + this.skillUploadSessionsDisposed = true + const sessions = this.skillUploadSessions + this.skillUploadSessions = null + await sessions?.dispose() + } + + getRuntimeId(): string { + return this.runtimeId + } + + resolveOrchestrationWorkerServer(selector: string): OrchestrationWorkerServer { + if (!this.orchestrationEnvironmentTransport) { + throw new OrchestrationError( + 'server_required', + 'Connected-server orchestration is unavailable in this runtime.' + ) + } + return this.orchestrationEnvironmentTransport.resolve(selector) + } + + async callOrchestrationWorkerServer( + selector: string, + method: string, + params: unknown, + timeoutMs?: number, + envelope?: RuntimeOrchestrationEnvelope, + internal?: { contractVerified?: boolean } + ): Promise { + if (!this.orchestrationEnvironmentTransport) { + throw new OrchestrationError( + 'server_required', + 'Connected-server orchestration is unavailable in this runtime.' + ) + } + if (isOrchestrationMutation(method, params) && !internal?.contractVerified) { + const statusResponse = await this.orchestrationEnvironmentTransport.call( + selector, + 'status.get', + undefined, + timeoutMs + ) + if (statusResponse.ok === false) { + throw new OrchestrationError( + statusResponse.error.code, + statusResponse.error.message, + statusResponse.error.data + ) + } + const status = statusResponse.result as RuntimeStatus + if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { + throw new OrchestrationError( + 'orchestration_migration_required', + 'The connected worker server does not support the current orchestration contract. No effects were applied.', + orchestrationMigrationData('runtime_capability_missing') + ) + } + } + const response = await this.orchestrationEnvironmentTransport.call( + selector, + method, + params, + timeoutMs, + method.startsWith('orchestration.') + ? { ...envelope, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } + : envelope + ) + if (response.ok === false) { + throw new OrchestrationError(response.error.code, response.error.message, response.error.data) + } + return response.result + } + + async syncOrchestrationFederation(runId?: string): Promise { + if (!this.orchestrationEnvironmentTransport) { + return + } + const dispatches = this.getOrchestrationDb().listActiveFederatedDispatches(runId) + await Promise.allSettled( + dispatches.map((dispatch) => this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id)) + ) + } + + syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + const db = this.getOrchestrationDb() + const current = this.orchestrationFederationSyncs.get(dispatchId) + if (current?.db === db) { + return current.promise + } + const sync = syncFederatedDispatch(this, dispatchId) + .then(() => { + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { + this.orchestrationFederationWarnings.delete(dispatchId) + } + }) + .catch((error: unknown) => { + if ( + this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync && + !this.orchestrationFederationWarnings.has(dispatchId) + ) { + console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) + this.orchestrationFederationWarnings.add(dispatchId) + } + throw error + }) + .finally(() => { + if (this.orchestrationFederationSyncs.get(dispatchId)?.promise !== sync) { + return + } + this.orchestrationFederationSyncs.delete(dispatchId) + if (!db.isFederatedDispatchRelayEligible(dispatchId)) { + releaseFederationAckCheckpoint(this, dispatchId) + } + }) + this.orchestrationFederationSyncs.set(dispatchId, { db, promise: sync }) + return sync + } + + async syncOrchestrationFederatedDispatchAfterCurrent(dispatchId: string): Promise { + const db = this.getOrchestrationDb() + const current = this.orchestrationFederationSyncs.get(dispatchId) + if (current?.db === db) { + await current.promise.catch(() => undefined) + } + await this.syncOrchestrationFederatedDispatch(dispatchId) + } + + ensureOrchestrationFederationRelay(runId?: string): void { + if (!this.orchestrationEnvironmentTransport) { + return + } + for (const dispatch of this.getOrchestrationDb().listActiveFederatedDispatches(runId)) { + if (this.orchestrationFederationTimers.has(dispatch.dispatch_id)) { + continue + } + const tick = () => { + if (!this.getOrchestrationDb().isFederatedDispatchRelayEligible(dispatch.dispatch_id)) { + const activeTimer = this.orchestrationFederationTimers.get(dispatch.dispatch_id) + if (activeTimer) { + clearInterval(activeTimer) + } + this.orchestrationFederationTimers.delete(dispatch.dispatch_id) + this.orchestrationFederationWarnings.delete(dispatch.dispatch_id) + return + } + void this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id).catch(() => undefined) + } + const timer = setInterval(tick, 1_000) + timer.unref?.() + this.orchestrationFederationTimers.set(dispatch.dispatch_id, timer) + tick() + } + this.ensureTerminalHistoryRecovery() + } + + private ensureTerminalHistoryRecovery(): void { + if ( + this.orchestrationTerminalHistoryRecoveryTimer || + this.orchestrationTerminalHistoryRecoveryInFlight + ) { + return + } + const generation = this.orchestrationFederationRelayGeneration + const recovery = this.recoverNextTerminalHistoryAcknowledgment(generation).catch((error) => { + console.warn('[orchestration] terminal federation acknowledgment recovery failed', error) + }) + this.orchestrationTerminalHistoryRecoveryInFlight = recovery + void recovery.finally(() => { + if (this.orchestrationTerminalHistoryRecoveryInFlight === recovery) { + this.orchestrationTerminalHistoryRecoveryInFlight = null + } + }) + } + + private async recoverNextTerminalHistoryAcknowledgment(generation: number): Promise { + const db = this.getOrchestrationDb() + let historical = db.findNextTerminalFederatedDispatchPendingAcknowledgment( + this.orchestrationTerminalRecoveryRowId + ) + if (!historical && this.orchestrationTerminalRecoveryRowId > 0) { + this.orchestrationTerminalRecoveryRowId = 0 + historical = db.findNextTerminalFederatedDispatchPendingAcknowledgment(0) + } + if (!historical) { + return + } + this.orchestrationTerminalRecoveryRowId = historical.rowId + await this.syncOrchestrationFederatedDispatch(historical.dispatchId).catch(() => undefined) + if (generation !== this.orchestrationFederationRelayGeneration) { + return + } + this.orchestrationTerminalHistoryRecoveryTimer = setTimeout(() => { + this.orchestrationTerminalHistoryRecoveryTimer = null + this.ensureTerminalHistoryRecovery() + }, 1_000) + this.orchestrationTerminalHistoryRecoveryTimer.unref?.() + } + + stopOrchestrationFederationRelay(): void { + this.orchestrationFederationRelayGeneration += 1 + for (const timer of this.orchestrationFederationTimers.values()) { + clearInterval(timer) + } + this.orchestrationFederationTimers.clear() + if (this.orchestrationTerminalHistoryRecoveryTimer) { + clearTimeout(this.orchestrationTerminalHistoryRecoveryTimer) + this.orchestrationTerminalHistoryRecoveryTimer = null + } + this.orchestrationTerminalHistoryRecoveryInFlight = null + this.orchestrationTerminalRecoveryRowId = 0 + this.orchestrationFederationWarnings.clear() + this.orchestrationFederationSyncs.clear() + clearFederationAckCheckpoints(this) + } + + getStartedAt(): number { + return this.startedAt + } + + private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const workspace = this.store + ?.getFolderWorkspaces?.() + .find((entry) => entry.id === scope.folderWorkspaceId) + if (!workspace) { + return null + } + if (workspace.executionHostId != null) { + return parseExecutionHostId(workspace.executionHostId)?.id ?? null + } + const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + } + const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId + const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) + return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + } + + private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + if (!hostId) { + throw new Error('folder_workspace_not_found') + } + return hostId + } + + private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null + } + + private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void { + this.store?.setWorkspaceSession?.( + session, + this.getWorkspaceSessionHostIdForWorktree(worktreeId) + ) + } + + private getKnownWorkspaceSessionWorktreeIds(): Set { + const repos = this.store?.getRepos?.() ?? [] + const repoIds = new Set(repos.map((repo) => repo.id)) + const hostIds = new Set(['local']) + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + const worktreeIds = new Set() + for (const hostId of hostIds) { + const session = this.store?.getWorkspaceSession?.(hostId) + for (const worktreeId of Object.keys(session?.tabsByWorktree ?? {})) { + if (repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { + worktreeIds.add(worktreeId) + } + } + } + return worktreeIds + } + + // Every execution host known to this runtime; knowledge is not coverage. + private listKnownExecutionHostIds( + additionalHostIds: Iterable = [], + includeConfiguredHosts = true + ): Set { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + for (const hostId of additionalHostIds) { + hostIds.add(hostId) + } + if (!includeConfiguredHosts) { + return hostIds + } + const repos = this.store?.getRepos?.() ?? [] + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + const projectGroups = this.store?.getProjectGroups?.() ?? [] + for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) { + if (workspace.executionHostId != null) { + const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id + if (explicitHostId) { + hostIds.add(explicitHostId) + } + continue + } + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups, + repos + }) + if (connection.kind === 'ssh') { + hostIds.add(toSshExecutionHostId(connection.connectionId)) + } + } + return hostIds + } + + private getWorkspaceSessionHydrationTargets( + includeAllPersistedWorktrees: boolean + ): Map { + const repos = this.store?.getRepos?.() ?? [] + const repoHostIdByRepoId = new Map( + repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const) + ) + const folderHostIdByWorkspaceId = new Map( + (this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => { + const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) + return [ + workspace.id, + connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + ] as const + }) + ) + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + + const targets = new Map() + for (const hostId of hostIds) { + const session = this.store?.getWorkspaceSession?.(hostId) + if (!session) { + continue + } + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + const scope = parseWorkspaceKey(worktreeId) + const ownerHostId = + scope?.type === 'folder' + ? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null) + : (repoHostIdByRepoId.get( + getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) + ) ?? LOCAL_EXECUTION_HOST_ID) + if ( + ownerHostId === hostId && + (includeAllPersistedWorktrees || + this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)) + ) { + targets.set(worktreeId, session) + } + } + } + return targets + } + + getStatus(): RuntimeStatus { + // Why: browser panes need a backend that can create and stream a page. A + // desktop renderer provides one via ; a headless serve provides one + // via the offscreen backend. Either way the same browser.screencast.v1 path + // works, so advertise it when either is present. browser.headless.v1 + // additionally tells clients this host owns browser pages with no renderer, + // so they must not fall back to a local desktop browser tab. + const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) + const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) + const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() + const canBrowse = hasRenderer || hasOffscreen + const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( + (capability) => + (capability !== 'browser.screencast.v1' || canBrowse) && + // Why: the nested-runtime E2E needs a real legacy transport without maintaining an old binary fixture. + (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || + capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && + (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || + capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && + (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) + ) + if (hasOffscreen || hasHeadlessCommands) { + capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) + } + // Why: certificate proceed is owned by the browser-hosting process for both + // desktop webviews and offscreen pages. Advertise whenever either backend + // can host a page so remote clients can surface Proceed Anyway (Unsafe). + if (canBrowse) { + capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) + } + // Why the cause and not one fixed sentence: the operator can only act on the reason + // that actually applies, and a host that says "set ORCA_BROWSER_EXECUTABLE" to someone + // who already set it sends them to fix a thing that is not broken. + const cause = canBrowse || hasHeadlessCommands ? null : runtimeBrowserUnavailableCause() + const degradations: RuntimeDegradation[] = cause + ? [ + { + code: BROWSER_UNAVAILABLE_ERROR_CODE, + capability: BROWSER_HEADLESS_RUNTIME_CAPABILITY, + message: browserUnavailableMessage(cause.reason, cause.detail), + reason: cause.reason, + ...(cause.detail ? { detail: cause.detail } : {}) + } + ] + : [] + // Why appended rather than merged into the ternary: PTY loss and browser loss are + // independent, and a host can be degraded on both at once. + const terminalDegradation = runtimeTerminalDegradation() + if (terminalDegradation) { + degradations.push(terminalDegradation) + } + return { + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + graphStatus: this.graphStatus, + authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), + liveTabCount: this.tabs.size, + liveLeafCount: this.leaves.size, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + // Why: headless orca serve cannot create/stream BrowserViews, so clients + // must not treat browser panes as supported just because runtime RPC is up. + capabilities, + ...(degradations.length > 0 ? { degradations } : {}), + worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS }, + hostPlatform: process.platform, + terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, + floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, + protocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } + + shouldRelayTerminalBrowserOpens(): boolean { + return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID + } + + // Why: scans the transcript-owning host's disk (correct by construction over + // RPC — a remote/SSH host scans its own disk). Delegates to the one shared + // cache so the desktop panel and the mobile screen never double-scan. + listAiVaultSessions(args?: AiVaultListArgs): Promise { + return listAiVaultSessions(args) + } + + resolveAiVaultSessionTitles( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal + ): Promise { + return resolveLocalAiVaultSessionTitles(requests, signal) + } + + prepareAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs + ): Promise { + return ( + this.prepareAiVaultSessionResumeFn?.(args) ?? Promise.resolve({ useRealCodexHome: false }) + ) + } + + setPtyController(controller: RuntimePtyController | null): void { + // Why: CLI terminal writes must go through the main-owned PTY registry + // instead of tunneling back through renderer IPC, or live handles could + // drift from the process they are supposed to control during reloads. + this.ptyController = controller + // A controller attached after restart must reconcile persisted PTY ids once; + // an otherwise idle runtime with no persisted terminals stays read-free. + if (controller && this.hasPersistedPtyReferences()) { + this.invalidatePtyLivenessSnapshot() + } + } + + private hasPersistedPtyReferences(): boolean { + const session = this.store?.getWorkspaceSession?.() + if (!session) { + return false + } + if ( + Object.values(session.tabsByWorktree ?? {}).some((tabs) => + tabs.some((tab) => tab.ptyId !== null) + ) + ) { + return true + } + return Object.values(session.terminalLayoutsByTabId ?? {}).some((layout) => + Object.values(layout?.ptyIdsByLeafId ?? {}).some((ptyId) => Boolean(ptyId)) + ) + } + + setNotifier(notifier: RuntimeNotifier | null): void { + this.notifier = notifier + // Why: run the one-shot fork-upstream backfill once a renderer is attached, + // so existing forks self-correct on launch and the result can be broadcast. + if (notifier && !this.forkBackfillStarted) { + this.forkBackfillStarted = true + void this.backfillForkUpstreams() + } + } + + onClientEvent( + listener: (event: RuntimeClientEvent) => void, + options?: { consumesTerminalSideEffects?: boolean } + ): () => void { + this.clientEventListeners.add(listener) + if (options?.consumesTerminalSideEffects === false) { + this.terminalSideEffectExcludedClientEventListeners.add(listener) + } else { + this.terminalSideEffectTitleGateKeysByClientEventListener.set(listener, new Map()) + } + this.refreshTerminalSideEffectConsumerAvailability() + return () => { + this.clientEventListeners.delete(listener) + this.terminalSideEffectExcludedClientEventListeners.delete(listener) + this.terminalSideEffectTitleGateKeysByClientEventListener.delete(listener) + this.refreshTerminalSideEffectConsumerAvailability() + } + } + + private countTerminalSideEffectConsumingClientEventListeners(): number { + return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size + } + + getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { + const events: RuntimeClientEvent[] = [] + const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => + a.worktreeId.localeCompare(b.worktreeId) + ) + for (const state of sleepStates) { + const committedPtyIds = new Set(state.ptyIds) + if (state.phase === 'stopping') { + const pendingPtyIds = Object.keys(state.terminalHandlesByPtyId) + .filter((ptyId) => !committedPtyIds.has(ptyId)) + .sort() + if (pendingPtyIds.length > 0) { + events.push({ + type: 'worktreeTerminalSleepState', + worktreeId: state.worktreeId, + generation: state.generation, + phase: 'started', + ptyIds: pendingPtyIds, + terminalHandles: this.getRecordedTerminalSleepHandles( + pendingPtyIds, + state.terminalHandlesByPtyId + ) + }) + } + } + if (state.ptyIds.length > 0) { + events.push({ + type: 'worktreeTerminalSleepState', + worktreeId: state.worktreeId, + generation: state.generation, + phase: 'committed', + ptyIds: [...state.ptyIds].sort(), + terminalHandles: this.getRecordedTerminalSleepHandles( + state.ptyIds, + state.terminalHandlesByPtyId + ) + }) + } + } + return events + } + + getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } + >[] { + return [...this.nativeChatLaunchDraftResolutionByTabId.values()] + .sort((a, b) => a.tabId.localeCompare(b.tabId)) + .map(({ tabId, text, createdAt }) => ({ + type: 'nativeChatLaunchDraftResolved', + tabId, + text, + createdAt + })) + } + + private emitClientEvent(event: RuntimeClientEvent): void { + // Why: filter inside live-Set delivery so a listener removed mid-fan-out + // receives nothing and each paired client gets one semantic title frame. + // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the + // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. + notifyRuntimeListeners( + this.clientEventListeners, + (listener) => { + if (event.type === 'terminalSideEffects') { + const filtered = this.filterTerminalSideEffectEventForClient(listener, event) + if (filtered) { + listener(filtered) + } + } else { + listener(event) + } + }, + 'client-event' + ) + } + + private filterTerminalSideEffectEventForClient( + listener: (event: RuntimeClientEvent) => void, + event: Extract + ): Extract | null { + const titleGateKeys = this.terminalSideEffectTitleGateKeysByClientEventListener.get(listener) + if (!titleGateKeys) { + return null + } + const facts = event.batch.facts.filter((fact) => { + if (fact.kind !== 'title') { + return true + } + const gateKey = this.makeDecorativeTitleGateKey(fact.rawTitle, fact.normalizedTitle) + if (titleGateKeys.get(event.batch.ptyId) === gateKey) { + return false + } + titleGateKeys.set(event.batch.ptyId, gateKey) + return true + }) + if (facts.length === 0) { + return null + } + return facts.length === event.batch.facts.length + ? event + : { ...event, batch: { ...event.batch, facts } } + } + + notifyNativeChatLaunchDraftResolved( + handle: string, + resolution: { text: string; createdAt: number } + ): void { + const owner = this.resolveNativeChatLaunchDraftOwner(handle) + if (!owner) { + return + } + const tombstone = { ...owner, ...resolution } + this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) + this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) + while ( + this.nativeChatLaunchDraftResolutionByTabId.size > + MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES + ) { + const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value + if (typeof oldestTabId !== 'string') { + break + } + this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) + } + this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) + this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) + this.emitClientEvent({ + type: 'nativeChatLaunchDraftResolved', + tabId: owner.tabId, + ...resolution + }) + } + + private resolveNativeChatLaunchDraftOwner( + handle: string + ): { tabId: string; worktreeId: string } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (!record.tabId.startsWith('pty:')) { + return { tabId: record.tabId, worktreeId: record.worktreeId } + } + const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null + const tabId = + pty?.tabId && !pty.tabId.startsWith('pty:') + ? pty.tabId + : parsePaneKey(pty?.paneKey ?? '')?.tabId + if (!pty || !tabId || tabId.startsWith('pty:')) { + return null + } + return { tabId, worktreeId: pty.worktreeId } + } + + private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( + resolution: NativeChatLaunchDraftResolutionTombstone + ): void { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (!worktreeIdsEqual(worktreeId, resolution.worktreeId)) { + continue + } + const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + if (next === snapshot) { + return + } + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...next, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + this.scheduleMobileSessionTabsChanged(worktreeId) + return + } + } + + private applyNativeChatLaunchDraftResolutionFence( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) + if ( + !resolution || + !worktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || + tab.launchDraft !== resolution.text || + tab.launchDraftCreatedAt !== resolution.createdAt + ) { + return tab + } + changed = true + const next = { ...tab } + delete next.launchDraft + delete next.launchDraftCreatedAt + return next + }) + return changed ? { ...snapshot, tabs } : snapshot + } + + private reconcileNativeChatLaunchDraftResolutionTombstones( + snapshot: RuntimeMobileSessionTabsSnapshot + ): void { + for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { + if (!worktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { + continue + } + const surfaces = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.parentTabId === tabId + ) + if ( + surfaces.length === 0 || + !surfaces.some( + (tab) => + tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt + ) + ) { + this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) + } + } + } + + private notifyWorktreesChanged(repoId: string): void { + this.invalidatePtyLivenessSnapshot() + this.notifier?.worktreesChanged(repoId) + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + + /** Detail-level worktree lifecycle tap (plugin event bus). The coarse + * worktreesChanged client event carries only repoId, which is not enough + * for subscribers that need the affected worktree's identity. + * Removal payloads carry no branch: the removal target resolves before + * the git worktree is torn down and only pins id + path. */ + onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void { + this.worktreeLifecycleListeners.add(listener) + return () => { + this.worktreeLifecycleListeners.delete(listener) + } + } + + private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + for (const listener of this.worktreeLifecycleListeners) { + try { + listener(event) + } catch (err) { + console.error('[runtime] worktree lifecycle listener threw', err) + } + } + } + + private notifyReposChanged(): void { + this.invalidatePtyLivenessSnapshot() + wakeFolderRepoGitUpgradeWatch() + this.notifier?.reposChanged() + this.emitClientEvent({ type: 'reposChanged' }) + } + + // Why: automation writes land in the automation service and IPC handlers, so + // like SSH state they need a public entry point onto the client-event stream. + // Old clients drop the unknown event type; nothing is negotiated for it. + notifyAutomationsChanged(payload: AutomationsChangedPayload = {}): void { + this.notifier?.automationsChanged?.(payload) + this.emitClientEvent({ type: 'automationsChanged', ...payload }) + } + + // Why: SSH state changes originate in main's ssh handlers, not in runtime + // methods, so they need a public entry point onto the client-event stream. + notifySshStateChanged(targetId: string, state: SshConnectionState): void { + this.bumpSshRelayRecoveryGeneration(targetId) + this.invalidateSshWorktreeScanCache(targetId) + if (state.status !== 'connected') { + this.cancelLegacyWorkerTerminalRecoveryRetry(`ssh:${targetId}`) + } + this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) + } + + notifySshRelayReady(targetId: string): void { + const generation = this.bumpSshRelayRecoveryGeneration(targetId) + const publish = async (): Promise => { + try { + await this.publishRecoveredSshMobileSessionTabs(targetId, generation) + } catch (error) { + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) === generation) { + console.warn('[runtime] failed to publish recovered SSH session tabs', { + targetId, + error + }) + } + } + } + const initialPublication = publish() + void initialPublication + void this.refreshRestoredOrchestrationAuthority(targetId) + .then(() => + this.reconcileLegacyWorkerTerminals({ + connectionId: targetId, + materializeRenderer: this.notifier !== null + }) + ) + .then(async () => { + await initialPublication + await publish() + }) + .catch((error) => { + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + return + } + console.warn('[orchestration] legacy worker reconcile failed on relay ready', { + targetId, + error + }) + }) + } + + private bumpSshRelayRecoveryGeneration(targetId: string): number { + const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 + this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) + return generation + } + + private async publishRecoveredSshMobileSessionTabs( + targetId: string, + generation: number + ): Promise { + const repoIds = new Set( + (this.store?.getRepos() ?? []) + .filter((repo) => repo.connectionId === targetId) + .map((repo) => repo.id) + ) + if (repoIds.size === 0) { + return + } + const worktreeIds = new Set() + for (const worktreeId of [ + ...this.getKnownWorkspaceSessionWorktreeIds(), + ...this.mobileSessionTabsByWorktree.keys() + ]) { + const parsed = splitWorktreeId(worktreeId) + if (parsed && repoIds.has(parsed.repoId)) { + worktreeIds.add(worktreeId) + } + } + if (worktreeIds.size === 0) { + return + } + + // Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event. + for (const worktreeId of worktreeIds) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + await this.refreshMobileSessionPtyRecords() + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + return + } + for (const worktreeId of worktreeIds) { + this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) + } + } + + invalidateSshWorktreeScanCache(targetId: string): void { + this.invalidateSshWorktreeScanCacheInternal(targetId) + } + + // Why: renderer-initiated meta updates intentionally skip the renderer + // notifier (the renderer already applied them optimistically), but remote + // clients hold no optimistic copy and need the invalidation event. + notifyWorktreesChangedForRemoteClients(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + + // Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not. + notifyWorktreeCatalogChangedForRemoteClients(repoId: string): void { + this.invalidateWorktreeScanCacheForRepo(repoId) + const matchingRepos = this.store?.getRepos().filter((repo) => repo.id === repoId) ?? [] + if (matchingRepos.length !== 1 || matchingRepos[0]?.connectionId) { + return + } + this.notifyWorktreesChangedForRemoteClients(repoId) + } + + // Why: host-local repo IPC mutations never enter runtime methods, so paired + // clients need an explicit catalog invalidation; the local renderer already + // got its own repos:changed and must not be re-notified (#11994). + notifyReposChangedForRemoteClients(): void { + this.emitClientEvent({ type: 'reposChanged' }) + } + + /** Why an object with a required `navigationTarget`: 'caller' means the requester already owns + * the navigation, so a site that forgets to forward it silently reverts to broadcasting at + * every viewer (STA-2802). A required key makes omission a compile error, and naming it makes + * a hardcoded target visible at the call site instead of hiding in a positional tail. */ + private notifyActivateWorktree( + repoId: string, + worktreeId: string, + launch: { + setup?: CreateWorktreeResult['setup'] + startup?: WorktreeStartupLaunch + defaultTabs?: CreateWorktreeResult['defaultTabs'] + navigationTarget: RuntimeNavigationTarget | undefined + } + ): void { + const { setup, startup, defaultTabs } = launch + const navigation = launch.navigationTarget ?? 'all' + if (navigationTargetsHost(navigation)) { + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + if (navigationTargetsClients(navigation)) { + this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + } + + private notifyHostActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { + this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + } + + private notifyClientsActivateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] + ): void { + this.emitClientEvent( + toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs) + ) + } + + setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void { + this.agentBrowserBridge = bridge + } + + getAgentBrowserBridge(): AgentBrowserBridge | null { + return this.agentBrowserBridge + } + + setOffscreenBrowserBackend(backend: BrowserBackend | null): void { + this.offscreenBrowserBackend = backend + } + + getOffscreenBrowserBackend(): BrowserBackend | null { + return this.offscreenBrowserBackend + } + + setEmulatorBridge(bridge: EmulatorBridge | null): void { + this.emulatorBridge = bridge + } + + getEmulatorBridge(): EmulatorBridge | null { + return this.emulatorBridge + } + + attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + if ( + this.pendingHeadlessPromotionWindowId !== null && + windowId !== this.pendingHeadlessPromotionWindowId + ) { + return + } + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.pendingHeadlessPromotionWindowId = windowId + this.authoritativeWindowId = windowId + this.beginGraphReload(windowId) + return + } + if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.authoritativeWindowId = windowId + } + } + + private persistWindowlessPtyBindingsForDesktopAttach(): void { + if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { + return + } + const partitions = new Map< + ExecutionHostId, + { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } + >() + for (const pty of this.ptysById.values()) { + if (!pty.connected || !pty.tabId) { + continue + } + const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) + const session = this.store.getWorkspaceSession(hostId) + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + continue + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { + continue + } + const partition = partitions.get(hostId) ?? { session, ptys: [] } + partition.ptys.push(pty) + partitions.set(hostId, partition) + } + + for (const [hostId, { session, ptys }] of partitions) { + // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...ptys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...ptys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of ptys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession( + { + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }, + hostId + ) + } + } + + syncWindowGraph( + windowId: number, + graph: RuntimeSyncWindowGraph | RuntimeRendererSyncWindowGraph + ): RuntimeSyncWindowGraphResult { + // `tabs` and several downstream indexes are keyed only by tab id. Reject + // malformed persisted/mirrored graphs before authority or graph state is + // changed; choosing a winner would route PTYs to the wrong worktree. + assertUniqueRuntimeGraphTabIds(graph.tabs) + if ( + windowId !== HEADLESS_RUNTIME_WINDOW_ID && + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + this.headlessGraphFallbackAvailable + ) { + if (windowId !== this.pendingHeadlessPromotionWindowId) { + throw new Error('Runtime graph publisher does not match the pending desktop promotion') + } + // Why: a renderer may publish after a failed promotion was restored to + // headless authority; accepting that late healthy graph is self-healing. + this.attachWindow(windowId) + } + if (this.authoritativeWindowId === null) { + this.authoritativeWindowId = windowId + } + if (windowId !== this.authoritativeWindowId) { + throw new Error('Runtime graph publisher does not match the authoritative window') + } + const rendererGeneration = + windowId === HEADLESS_RUNTIME_WINDOW_ID + ? null + : 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string' + ? graph.rendererGeneration + : undefined + if ( + typeof rendererGeneration === 'string' && + rendererGeneration === this.rendererGeneration && + this.graphStatus !== 'ready' + ) { + throw new Error('Runtime graph publisher belongs to a superseded renderer generation') + } + if (windowId === HEADLESS_RUNTIME_WINDOW_ID) { + this.headlessGraphFallbackAvailable = true + this.rendererGeneration = null + } + + const graphWasReady = this.graphStatus === 'ready' + const previousTabs = this.tabs + const previousLeaves = this.leaves + this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) + const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) + const mobileSessionResyncWorktrees = new Set() + const changedMobileWorktrees = this.syncMobileSessionTabs( + graph.mobileSessionTabs, + graph.unchangedMobileSessionWorktrees, + mobileSessionResyncWorktrees + ) + const nextLeaves = new Map() + const graphSyncedAt = this.nextTitleObservationSequence() + + // Why: renderer reloads can briefly republish the same leaf with no ptyId; + // keep live CLI handles usable while the UI graph rebuilds. + const preserveLivePtysDuringReload = this.graphStatus === 'reloading' + for (const leaf of lifecycleLeaves) { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const existing = this.leaves.get(leafKey) + const ptyId = + preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId + ? existing.ptyId + : leaf.ptyId + const ptyGeneration = + existing && existing.ptyId !== ptyId + ? existing.ptyGeneration + 1 + : (existing?.ptyGeneration ?? 0) + const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined + const tailSource = existing?.ptyId === ptyId ? existing : existingPty + + nextLeaves.set(leafKey, { + ...leaf, + ptyId, + ptyGeneration, + connected: ptyId !== null, + writable: this.graphStatus === 'ready' && ptyId !== null, + lastOutputAt: tailSource?.lastOutputAt ?? null, + lastExitCode: tailSource?.lastExitCode ?? null, + lastExitCause: tailSource?.lastExitCause ?? null, + tailBuffer: tailSource?.tailBuffer ?? [], + tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], + tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, + tailPartialLine: tailSource?.tailPartialLine ?? '', + tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', + tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, + tailTruncated: tailSource?.tailTruncated ?? false, + tailLinesTotal: tailSource?.tailLinesTotal ?? 0, + preview: tailSource?.preview ?? '', + waitBlockedAt: tailSource?.waitBlockedAt ?? null, + lastAgentStatus: tailSource?.lastAgentStatus ?? null, + lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, + lastOscTitle: tailSource?.lastOscTitle ?? null, + lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, + paneTitleUpdatedAt: + existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle + ? existing.paneTitleUpdatedAt + : graphSyncedAt + }) + + if (leaf.ptyId) { + this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf) + }) + } + + if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { + // Why: mobile can subscribe while the pane is waiting for its first PTY. + // Keep that handle usable after the recovery mount binds it. + const adoptedFirstPty = + existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if (!adoptedFirstPty) { + this.invalidateLeafHandle(leafKey) + } + } + } + + // Why: computed BEFORE preserving stale leaves so preservation can refuse a + // leaf whose PTY the incoming graph already rebound to a live leaf. Two + // leaves on one PTY resolve to the same handle (handles are ptyId-keyed) and + // crash paired clients with a duplicate React key. + const nextPtyIds = new Set( + [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) + ) + for (const oldLeafKey of this.leaves.keys()) { + if (!nextLeaves.has(oldLeafKey)) { + const oldLeaf = this.leaves.get(oldLeafKey) + const retainedIncarnation = oldLeaf?.ptyId + ? this.handleByPtyIncarnation.get(oldLeaf.ptyId) + : undefined + if ( + preserveLivePtysDuringReload && + oldLeaf?.ptyId && + (this.handleByPtyId.has(oldLeaf.ptyId) || + (retainedIncarnation && + retainedIncarnation.incarnationId === + this.ptysById.get(oldLeaf.ptyId)?.incarnationId)) && + !nextPtyIds.has(oldLeaf.ptyId) + ) { + // Why: the first reload graph can precede pane rebinding; the live PTY incarnation still owns its handle. + nextLeaves.set(oldLeafKey, oldLeaf) + nextPtyIds.add(oldLeaf.ptyId) + } else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) { + // Why: the incoming graph already rebound this PTY to a live leaf (e.g. + // a woken agent re-keyed to a new leaf during renderer reload). Keeping + // the old leaf too would put two leaves on ONE PTY, which emit the same + // terminal handle and crash paired clients. Drop the stale leaf; if its + // handle is the shared ptyId-keyed one it belongs to the live leaf now, + // so release only this dead leaf key's alias. A leaf-unique handle has + // no next owner — invalidate it so in-flight CLI waiters fail fast + // instead of hanging on a dead leaf. + const oldHandle = this.handleByLeafKey.get(oldLeafKey) + const incarnationHandle = retainedIncarnation?.handle + if ( + oldHandle !== undefined && + (oldHandle === this.handleByPtyId.get(oldLeaf.ptyId) || oldHandle === incarnationHandle) + ) { + this.handleByLeafKey.delete(oldLeafKey) + } else { + this.invalidateLeafHandle(oldLeafKey) + } + } else { + this.invalidateLeafHandle(oldLeafKey) + } + } + } + + for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { + if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { + this.detachedPreAllocatedLeaves.delete(ptyId) + continue + } + nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf) + nextPtyIds.add(ptyId) + } + + this.leaves = nextLeaves + this.rebuildLeafPtyIndex() + this.reconcilePtyIncarnationHandles() + // Why: the emitted client payload is a function of the stored snapshot AND + // the tab/leaf graph (handles/titles/connected resolve from leaf state), so + // a graph-only change — e.g. a restored leaf binding its ptyId while the + // snapshot pair is unchanged — must also fan out, or a paired client stays + // on pending-handle forever. Schedule the union on the same 50ms trailing + // edge as the OSC-title path; the coalescer emit reads the latest state at + // fire time so no final version is ever lost. + for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( + previousTabs, + previousLeaves + )) { + if (changedMobileWorktrees.has(worktreeId)) { + continue + } + const stored = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!stored) { + continue + } + // Why: web clients drop same-epoch frames whose version isn't strictly + // newer, so a graph-only change must mint a fresh stored version (like + // the PTY touch path does) or the re-emitted payload — e.g. the + // pending-handle → ready flip — is discarded and the client stays stale. + // The accepted-renderer tracking is untouched: this is a main-local bump. + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...stored, + snapshotVersion: stored.snapshotVersion + 1 + }) + changedMobileWorktrees.add(worktreeId) + } + for (const worktreeId of changedMobileWorktrees) { + if (this.mobileSessionTabsByWorktree.has(worktreeId)) { + this.scheduleMobileSessionTabsChanged(worktreeId) + } + } + // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. + const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId + this.markGraphReady(windowId) + if ( + isAuthoritativeGraphPublisher && + (windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined) + ) { + if (mobileSessionResyncWorktrees.size === 0) { + this.markSessionTabsInventoryPublished() + } else { + this.sessionTabsInventoryPublicationEpoch = null + } + } + if (rendererGeneration !== undefined) { + this.rendererGeneration = rendererGeneration + } + for (const leaf of this.leaves.values()) { + this.adoptPreAllocatedHandle(leaf) + const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if ( + this._orchestrationDb && + leaf.lastAgentStatus === 'idle' && + leaf.lastAgentStatusObservedLive && + leaf.writable && + (!graphWasReady || + previousLeaf?.ptyId !== leaf.ptyId || + !previousLeaf.writable || + previousLeaf.lastAgentStatus !== 'idle' || + !previousLeaf.lastAgentStatusObservedLive) + ) { + this.deliverPendingMessagesForLeaf(leaf) + } + } + + // Why: createTerminal waits for the renderer's graph sync to populate the + // new leaf so it can return a handle. Drain callbacks after leaves update. + for (const cb of [...this.graphSyncCallbacks]) { + cb() + } + + const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() + const nativeChatLaunchDraftResolutions = + this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( + ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) + ) + return { + ...this.getStatus(), + ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), + ...(mobileSessionResyncWorktrees.size > 0 + ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } + : {}) + } + } + + // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and + // this.leaves, so any tab/leaf delta a graph sync installs can flip the + // client payload (pending-handle → ready, tab title) with zero change to the + // stored snapshot. Compare exactly the projection-relevant fields and report + // the affected worktrees; false positives only cost a coalesced no-op emit. + private collectMobileVisibleGraphChangedWorktrees( + previousTabs: Map, + previousLeaves: Map + ): Set { + const changed = new Set() + for (const [tabId, tab] of this.tabs) { + const prev = previousTabs.get(tabId) + if (!prev || prev.title !== tab.title) { + changed.add(tab.worktreeId) + } + } + for (const [tabId, tab] of previousTabs) { + if (!this.tabs.has(tabId)) { + changed.add(tab.worktreeId) + } + } + for (const [leafKey, leaf] of this.leaves) { + const prev = previousLeaves.get(leafKey) + if ( + !prev || + prev.ptyId !== leaf.ptyId || + prev.connected !== leaf.connected || + prev.paneTitle !== leaf.paneTitle + ) { + changed.add(leaf.worktreeId) + } + } + for (const [leafKey, leaf] of previousLeaves) { + if (!this.leaves.has(leafKey)) { + changed.add(leaf.worktreeId) + } + } + return changed + } + + async listMobileSessionTabs( + worktreeSelector: string, + clientNavigationId?: string + ): Promise { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + if (explicitWorktreeId) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) + await this.refreshMobileSessionPtyRecords(explicitWorktreeId) + this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId) + return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) + await this.refreshMobileSessionPtyRecords() + this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id) + return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) + } + + async listAllMobileSessionTabs( + clientNavigationId?: string + ): Promise { + return (await this.listAllMobileSessionTabsWithChangeSequence(clientNavigationId)).snapshots + } + + async listAllMobileSessionTabsWithChangeSequence(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + changeSequence: number + }> { + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + return { snapshots: inventory.snapshots, changeSequence: inventory.changeSequence } + } + + private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + changeSequence: number + }> { + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + const ptyInventory = await this.refreshMobileSessionPtyInventory() + this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) + const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => + this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + ) + return { snapshots, ptyInventory, changeSequence: this.mobileSessionTabsChangeSequence } + } + + async listAllMobileSessionTabsInventory( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + const { snapshots, authoritative } = + await this.listAllMobileSessionTabsInventoryWithChangeSequence(clientNavigationId, signal) + return { snapshots, ...(authoritative ? { authoritative } : {}) } + } + + async listAllMobileSessionTabsInventoryWithChangeSequence( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true + changeSequence: number + }> { + this.assertSessionTabsInventoryRequestActive(signal) + const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + const primed = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if ( + primedPublicationEpoch !== null && + this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch + ) { + return await this.settleSessionTabsInventory(primed, clientNavigationId, signal) + } + while (true) { + const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + if (publicationEpoch === null) { + await this.waitForSessionTabsInventoryPublication(signal) + continue + } + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) { + return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal) + } + } + } + + // Why: a failed census only invalidates the emptiness verdict, never the + // list. An incomplete census usually means a concurrent scan invalidated + // this one mid-relaunch and daemon-backed tabs could not be restored yet, so + // retry the collection once; a still-incomplete retry serves its snapshots + // unlabeled rather than erroring the request. + private async settleSessionTabsInventory( + inventory: { + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + changeSequence: number + }, + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true + changeSequence: number + }> { + if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { + return { + snapshots: inventory.snapshots, + authoritative: true, + changeSequence: inventory.changeSequence + } + } + const retried = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + // Why: the retry ran outside the epoch fence, so it never claims authority. + return { snapshots: retried.snapshots, changeSequence: retried.changeSequence } + } + + supportsAuthoritativeSessionTabsInventory(): boolean { + return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' + } + + private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + } + + private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean { + if (!inventory) { + return false + } + const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds) + return ![...knownHostIds].some((hostId) => { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId) + }) + } + + private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { + if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const cleanup = (): void => { + this.sessionTabsInventoryWaiters.delete(onPublished) + signal?.removeEventListener('abort', onAbort) + } + const onPublished = (): void => { + cleanup() + resolve() + } + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + this.sessionTabsInventoryWaiters.add(onPublished) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + onPublished() + } + }) + } + + private getAuthoritativeSessionTabsInventoryEpoch(): number | null { + return this.graphStatus === 'ready' && + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch + ? this.rendererGraphEpoch + : null + } + + private markSessionTabsInventoryPublished(): void { + if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) { + return + } + this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch + for (const publish of [...this.sessionTabsInventoryWaiters]) { + publish() + } + } + + private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + worktreeId?: string, + options: { + force?: boolean + allowAttachedWindow?: boolean + onlyRuntimeOwnedTerminals?: boolean + runtimeOwnedTerminalCandidateKnown?: boolean + workspaceSession?: WorkspaceSessionState + } = {} + ): Set { + // Why: report which worktrees were reconciled in place so callers don't + // reconcile them a second time (see notifyMobileSessionTabsChanged). + const reconciledWorktreeIds = new Set() + if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { + return reconciledWorktreeIds + } + const session = + options.workspaceSession ?? + (worktreeId + ? this.getWorkspaceSessionForWorktree(worktreeId) + : this.store?.getWorkspaceSession?.()) + if (!session) { + return reconciledWorktreeIds + } + // Why: with no runtime-owned candidate in the session and no offscreen + // browser backend, this hydrate provably builds zero tabs for + // every worktree — skip the per-worktree rebuild entirely (hot on every + // graph sync). Scoped to onlyRuntimeOwnedTerminals so full hydrates are + // untouched. + if ( + options.onlyRuntimeOwnedTerminals === true && + !this.offscreenBrowserBackend && + getRuntimeBrowserPageRegistry(this).listPages(worktreeId ?? '').length === 0 && + options.runtimeOwnedTerminalCandidateKnown !== true && + !(worktreeId + ? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( + session, + worktreeId, + session.tabsByWorktree[worktreeId] ?? [] + ) + : this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)) + ) { + return reconciledWorktreeIds + } + const entries = + worktreeId !== undefined + ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) + : Object.entries(session.tabsByWorktree ?? {}) + // Why: workspaceSession keys are `${repoId}::${path}` and are not pruned when + // a repo disappears from this client's view (e.g. removed on another client, + // or a stale browser-persisted session). Hydrating such a key would surface a + // phantom "unknown"/duplicate workspace with no live repo behind it. Only + // hydrate sessions whose repo still exists; leave unparseable keys alone. + // Resolved lazily so unparseable keys (floating terminals) never pay for a + // repo inventory on the hot poll path, and `null` when the store cannot + // report repos — an unavailable list must not read as "every repo is gone". + let liveRepoIds: Set | null | undefined + for (const [entryWorktreeId, persistedTabs] of entries) { + const ownerRepoId = splitWorktreeIdForFilesystem(entryWorktreeId)?.repoId + if (ownerRepoId) { + if (liveRepoIds === undefined) { + const knownRepos = this.store?.getRepos?.() + liveRepoIds = knownRepos ? new Set(knownRepos.map((repo) => repo.id)) : null + } + if (liveRepoIds && !liveRepoIds.has(ownerRepoId)) { + continue + } + } + const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) + if ( + existing && + existing.tabs.length > 0 && + options.force !== true && + options.onlyRuntimeOwnedTerminals !== true + ) { + // Why: terminals are stable/persisted so we normally skip a rebuild, but + // offscreen browser tabs are live and may have been created/closed since. + // Reconcile just the browser tabs against the live bridge instead of + // leaving a stale snapshot that omits a freshly-opened browser tab. + this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + reconciledWorktreeIds.add(entryWorktreeId) + continue + } + const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( + entryWorktreeId, + persistedTabs, + session + ).filter( + (tab) => + options.onlyRuntimeOwnedTerminals !== true || + this.hasServeOrSshOwnedBinding(tab) || + this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) + ) + // Why: offscreen browser panes are live-only (no persisted session entry), + // so include them on every hydrate regardless of the onlyRuntimeOwnedTerminals + // filter, which is about terminal PTY ownership and never applies to browsers. + const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) + const tabs: RuntimeMobileSessionSnapshotTab[] = [...terminalTabs, ...browserTabs] + if (tabs.length === 0) { + continue + } + const activeTab = this.pickHeadlessActiveTerminalTab(terminalTabs) + const tabOrder = [ + ...this.collectHeadlessParentTabOrder(terminalTabs), + ...browserTabs.map((tab) => tab.id) + ] + const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) + const mergedTabs = + options.onlyRuntimeOwnedTerminals === true && existing + ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) + : tabs + const mergedActiveTab = + existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? + activeTab ?? + mergedTabs[0] ?? + null + const mergedTerminalTabs = mergedTabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + const mergedBrowserOrder = mergedTabs + .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') + .map((tab) => tab.id) + // Why: a persisted multi-group split must be restored on cold rebuild, or + // the headless serve coalesces the user's group layout back into one group + // (the persisted tabGroups/tabGroupLayouts would otherwise be write-only). + const persistedGroups = session.tabGroups?.[entryWorktreeId] + const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] + const hasPersistedSplit = + options.onlyRuntimeOwnedTerminals !== true && + persistedGroups !== undefined && + persistedGroups.length > 1 + const activeTopLevelId = mergedActiveTab + ? mergedActiveTab.type === 'terminal' + ? mergedActiveTab.parentTabId + : mergedActiveTab.id + : null + const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit + ? this.appendBrowserTabOrder( + this.distributeHeadlessTabsAcrossGroups( + persistedGroups.map((group) => ({ + id: group.id, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })), + this.collectHeadlessParentTabOrder(mergedTerminalTabs), + activeTopLevelId + ), + mergedBrowserOrder, + undefined, + // Why: distribute drops browser ids (terminal-only), so carry each + // browser's persisted group forward instead of coalescing left. + this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) + ) + : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroups + ? this.appendBrowserTabOrder( + this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ), + mergedBrowserOrder + ) + : [ + { + id: groupId, + activeTabId: mergedActiveTab?.id + ? (activeTab?.parentTabId ?? mergedActiveTab.id) + : (tabOrder[0] ?? null), + tabOrder + } + ] + // Why: merging runtime tabs INTO a renderer publication must not reclass + // the snapshot as headless-built — the preservation predicate would then + // treat the renderer's own tabs as runtime-owned and resurrect tabs the + // renderer later closes. Keep the renderer base epoch with a merge suffix + // (idempotent) so ownership stays derivable from the epoch. + const mergedIntoRendererPublication = + options.onlyRuntimeOwnedTerminals === true && + existing !== undefined && + !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: existing?.worktree ?? entryWorktreeId, + publicationEpoch: mergedIntoRendererPublication + ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) + : `headless-hydrated:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: existing?.activeGroupId ?? groupId, + activeTabId: mergedActiveTab?.id ?? null, + activeTabType: mergedActiveTab?.type ?? null, + tabGroups: nextTabGroups, + // Why: the runtime-owned rebuild runs on every graph sync — carry the + // existing split layout forward or each sync drops it and fans out. + ...(hasPersistedSplit && persistedLayout + ? { tabGroupLayout: persistedLayout } + : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroupLayout + ? { tabGroupLayout: existing.tabGroupLayout } + : {}), + tabs: mergedTabs + } + // Why: the runtime-owned hydrate runs on EVERY graph sync; when the rebuilt + // projection matches the existing snapshot, keep the existing object and + // (epoch, version) untouched so identity-based change detection stays a + // pure no-op and unchanged runtime/browser worktrees never fan out. + if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { + continue + } + this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) + } + return reconciledWorktreeIds + } + + // Why: content equality for the hydrate's idempotence check — compares every + // client-visible field EXCEPT publicationEpoch/snapshotVersion (both are + // freshly minted on each rebuild and would defeat the comparison). Tab and + // group objects are rebuilt each hydrate, so compare by value, not identity. + private headlessMobileSnapshotContentUnchanged( + existing: RuntimeMobileSessionTabsSnapshot, + next: RuntimeMobileSessionTabsSnapshot + ): boolean { + if ( + existing.worktree !== next.worktree || + existing.activeGroupId !== next.activeGroupId || + existing.activeTabId !== next.activeTabId || + existing.activeTabType !== next.activeTabType + ) { + return false + } + // Why: this runs per persisted worktree on EVERY graph sync whenever a + // serve PTY exists, so compare structurally instead of stable-stringifying + // both sides (which allocated six full serialized trees per worktree). + return ( + this.mobileSnapshotValueEqual(existing.tabs, next.tabs) && + this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) && + this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null) + ) + } + + // Deep structural equality over plain snapshot JSON (objects/arrays/scalars). + // Key order is irrelevant; a mismatch only costs a coalesced no-op emit. + private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + for (let index = 0; index < a.length; index++) { + if (!this.mobileSnapshotValueEqual(a[index], b[index])) { + return false + } + } + return true + } + if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + if (aKeys.length !== Object.keys(bRecord).length) { + return false + } + for (const key of aKeys) { + if ( + !Object.hasOwn(bRecord, key) || + !this.mobileSnapshotValueEqual(aRecord[key], bRecord[key]) + ) { + return false + } + } + return true + } + return false + } + + // Why: keep an existing snapshot's browser tabs in sync with the live bridge + // without rebuilding stable terminal state. Replaces browser entries with the + // current live set and rewrites the browser portion of the primary group order. + private reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + existing: RuntimeMobileSessionTabsSnapshot + ): void { + const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) + const liveIds = liveBrowserTabs.map((tab) => tab.id) + const existingBrowserTabs = existing.tabs.filter( + (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' + ) + const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) + if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { + return + } + const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') + const nextTabs: RuntimeMobileSessionSnapshotTab[] = [...nonBrowserTabs, ...liveBrowserTabs] + const liveIdSet = new Set(liveIds) + const tabGroups = this.appendBrowserTabOrder( + (existing.tabGroups ?? []).map((group) => ({ + ...group, + // Drop closed browser ids; appendBrowserTabOrder re-adds the live ones. + tabOrder: group.tabOrder.filter( + (id) => liveIdSet.has(id) || !existingBrowserIds.includes(id) + ) + })), + liveIds + ) + const activeStillPresent = nextTabs.some((tab) => tab.id === existing.activeTabId) + const active = activeStillPresent + ? null + : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...existing, + publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, + snapshotVersion: existing.snapshotVersion + 1, + ...(activeStillPresent + ? {} + : { activeTabId: active?.id ?? null, activeTabType: active?.type ?? null }), + tabGroups, + tabs: nextTabs + }) + } + + // Why: browser session tabs have no parentTabId so the terminal-only group + // builder drops them from tabOrder; this re-adds their ids to a group. + // Browser tabs are live-only (no persisted session entry), but their GROUP + // membership must still survive snapshot rebuilds like terminals'. The + // passed-in groups already encode each browser's group (carried from the prior + // snapshot / persisted tabGroups), so keep each existing browser id where it + // is; only a genuinely-new browser id goes to its create-target group (when + // that group exists) and otherwise to the first group. Previously every + // browser was force-pushed into group[0], so opening a browser in the right + // split group always snapped it back to the left on the next rebuild. + private appendBrowserTabOrder( + groups: readonly RuntimeMobileSessionTabGroup[], + browserTabIds: readonly string[], + newTabAssignment?: { tabId: string; groupId: string }, + // browserPageId -> groupId from the prior/persisted groups. The terminal + // distributor rebuilds tabOrder from terminal ids only and drops browser + // ids, so this carries each browser's group across rebuilds. + priorGroupByBrowserId?: ReadonlyMap + ): RuntimeMobileSessionTabGroup[] { + if (browserTabIds.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + if (next.length === 0) { + return next + } + const groupById = new Map(next.map((group) => [group.id, group])) + const ownerGroupByTabId = new Map() + for (const group of next) { + for (const id of group.tabOrder) { + ownerGroupByTabId.set(id, group) + } + } + for (const id of browserTabIds) { + if (ownerGroupByTabId.has(id)) { + continue + } + const priorGroupId = priorGroupByBrowserId?.get(id) + const targetGroup = + (newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ?? + (priorGroupId ? groupById.get(priorGroupId) : undefined) ?? + next[0]! + targetGroup.tabOrder.push(id) + } + return next + } + + // browserPageId -> groupId from a set of groups (the persisted/prior layout), + // so a browser stays in its group across rebuilds that drop browser ids. + private collectBrowserGroupAssignment( + groups: readonly RuntimeMobileSessionTabGroup[] | undefined, + browserTabIds: readonly string[] + ): Map { + const browserIdSet = new Set(browserTabIds) + const assignment = new Map() + for (const group of groups ?? []) { + for (const id of group.tabOrder) { + if (browserIdSet.has(id)) { + assignment.set(id, group.id) + } + } + } + return assignment + } + + private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith('serve-') + } + + private isSshOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseAppSshPtyId(ptyId) !== null + } + + private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean { + return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) => + this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs) + ) + } + + private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( + session: WorkspaceSessionState, + worktreeId: string, + tabs: WorkspaceSessionState['tabsByWorktree'][string] + ): boolean { + return tabs.some((tab) => { + if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { + return true + } + const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId + return ( + (leafPtyIds && + Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) || + // Why: expiry keeps pane coordinates so paired viewers can request a fresh shell. + this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null + ) + }) + } + + private getRecentExpiredSshLease( + worktreeId: string, + tabId: string, + leafId: string | undefined, + ptyId?: string + ): ReturnType>[number] | null { + const now = Date.now() + return ( + this.store + ?.getSshRemotePtyLeases?.() + .find( + (lease) => + lease.state === 'expired' && + lease.worktreeId === worktreeId && + lease.tabId === tabId && + (ptyId === undefined || lease.ptyId === ptyId) && + (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) && + lease.updatedAt <= now && + now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS + ) ?? null + ) + } + + private hasRecentExpiredSshLeasePane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return this.getRecentExpiredSshLease(worktreeId, tab.parentTabId, tab.leafId) !== null + } + + // Why: serve-* (local serve) and ssh:@@ (SSH relay) ids are minted + // ONLY for runtime-owned terminals and are preserved/re-hydrated, so tear them + // down even if the renderer adopted a view (else they resurrect). The daemon + // session form @@ is deliberately NOT here: the daemon + // mints it for ordinary renderer-owned local terminals too, so id shape can't + // classify ownership for that form — renderer-graph membership does (below). + private isServeOrSshOwnedPtyId(ptyId: string | null | undefined): boolean { + return this.isServeOwnedPtyId(ptyId) || this.isSshOwnedPtyId(ptyId) + } + + private hasServeOrSshOwnedBinding(tab: RuntimeMobileSessionTerminalTab): boolean { + if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { + return true + } + return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => + this.isServeOrSshOwnedPtyId(ptyId) + ) + } + + // Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime + // terminal died and was de-persisted, so id shape alone must not preserve it + // against a renderer publication. Require the binding to be backed by a live + // PTY or by the persisted workspace session (a dormant persisted serve/SSH + // binding is still re-hydratable, so it stays preserved). + private hasLiveOrPersistedServeOrSshOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const boundPtyIds = [ + tab.ptyId, + ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) + const boundSshPtyIds = boundPtyIds.filter((ptyId) => this.isSshOwnedPtyId(ptyId)) + if (boundPtyIds.length === 0) { + return this.hasRecentExpiredSshLeasePane(worktreeId, tab) + } + // Why: exited PTY records are archived in ptysById, so require a connected + // record — a dead serve shell whose persisted binding is also gone must + // stop being preserved. + if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { + return true + } + const now = Date.now() + if ( + boundPtyIds.some((ptyId) => { + const pty = this.ptysById.get(ptyId) + return ( + pty?.connectionId != null && + pty.lastExitCode != null && + pty.lastExitCode < 0 && + pty.disconnectedAt != null && + now - pty.disconnectedAt <= SSH_PANE_RECOVERY_GRACE_MS + ) + }) + ) { + // Why: an abnormal SSH transport exit can beat paired-viewer recovery; retain its pane briefly so the HUB remains addressable. + return true + } + if ( + now - this.startedAt <= SSH_PANE_RECOVERY_GRACE_MS && + boundSshPtyIds.some((ptyId) => { + const pty = this.ptysById.get(ptyId) + return !pty || (!pty.connected && pty.lastExitCode === null) + }) + ) { + // Why: after a HUB restart, failed SSH reattach can remove persistence before the fresh runtime records an exit; keep the pane reachable for ensure. + return true + } + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session) { + return false + } + const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.parentTabId + ) + if (!persistedTab) { + return false + } + const persistedPtyIds = new Set( + [ + persistedTab.ptyId, + ...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {}) + ].filter((ptyId): ptyId is string => typeof ptyId === 'string') + ) + return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) + } + + private hasLiveRuntimeSessionOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + return pty?.connected === true && pty.runtimeSessionOwned + } + + private clearRuntimeSessionOwnershipForMobileTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + parentTabId: string + ): void { + for (const tab of snapshot.tabs) { + if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) { + continue + } + const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' + ) + for (const ptyId of ptyIds) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) { + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(pty.ptyId, false) + } + } + } + } + + private getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] { + return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 + ) + } + + private clearRuntimeSessionOwnershipForMobileTerminalLeaf( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + for (const ptyId of this.getMobileTerminalLeafPtyIds(tab)) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === worktreeId && pty.tabId === tab.parentTabId) { + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(pty.ptyId, false) + } + } + } + + // Why: only positive evidence that the persisted parent dropped this leaf may + // release it — a parent with no persisted layout is no evidence of a split. + private persistedParentStillBindsMobileTerminalLeaf( + session: WorkspaceSessionState, + persistedParent: TerminalTab, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const layout = session.terminalLayoutsByTabId?.[tab.parentTabId] + if (!layout) { + return true + } + if ( + typeof layout.ptyIdsByLeafId?.[tab.leafId] === 'string' || + collectLayoutLeafIdsInOrder(layout.root).includes(tab.leafId) + ) { + return true + } + // Why: renderer and headless sources can derive different leafIds for one + // surface, so a still-bound PTY id outranks a leafId that no longer matches. + const leafPtyIds = new Set(this.getMobileTerminalLeafPtyIds(tab)) + if (leafPtyIds.size === 0) { + return true + } + return [persistedParent.ptyId, ...Object.values(layout.ptyIdsByLeafId ?? {})].some( + (ptyId) => typeof ptyId === 'string' && leafPtyIds.has(ptyId) + ) + } + + // Why: omitted from the publication AND from persistence = durably closed, so release + // ownership — else a lagging or failed kill preserves the tab back into every merge. + // A create still in flight has not been retired, only not published yet. + private releaseRuntimeSessionOwnershipForRendererRetiredTabs( + incoming: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): void { + if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) { + return + } + const worktreeId = existing.worktree + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const persistedTabs = session?.tabsByWorktree?.[worktreeId] + if (!session || !persistedTabs) { + return + } + const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab])) + const incomingIdentityKeys = new Set( + incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) + for (const tab of existing.tabs) { + if (tab.type !== 'terminal') { + continue + } + if ( + this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${tab.parentTabId}`) || + this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + incomingIdentityKeys.has(id) + ) || + !this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab) + ) { + continue + } + const persistedParent = persistedTabsById.get(tab.parentTabId) + if (!persistedParent) { + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, existing, tab.parentTabId) + continue + } + // Why: a split parent outlives its retired leaf, so releasing the parent's + // PTYs would retire the surviving sibling with it. + if (!this.persistedParentStillBindsMobileTerminalLeaf(session, persistedParent, tab)) { + this.clearRuntimeSessionOwnershipForMobileTerminalLeaf(worktreeId, tab) + } + } + } + + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) + // only when the renderer can't durably tear it down: either it's serve/SSH + // (preserved + re-hydrated, would resurrect) or the renderer graph never + // published it (a leaked/unadopted shell — incl. daemon-session `@@` tabs the + // host materialized but the renderer never showed). A tab the renderer graph + // DOES list — including an ordinary daemon-backed local terminal or a pending + // tab whose PTY hasn't bound — is renderer-owned: delegate, do not de-persist. + private isRuntimeOwnedHeadlessMobileTab( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + if (this.hasServeOrSshOwnedBinding(tab)) { + return true + } + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + if (pty && this.isServeOrSshOwnedPtyId(pty.ptyId)) { + return true + } + return !this.tabs.has(tab.parentTabId) + } + + private mergeMobileSessionSnapshotTabs( + baseTabs: readonly RuntimeMobileSessionSnapshotTab[], + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): RuntimeMobileSessionSnapshotTab[] { + const seenIds = new Set() + const merged: RuntimeMobileSessionSnapshotTab[] = [] + const add = (tab: RuntimeMobileSessionSnapshotTab): void => { + const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) + if (ids.some((id) => seenIds.has(id))) { + return + } + for (const id of ids) { + seenIds.add(id) + } + merged.push(tab) + } + for (const tab of baseTabs) { + add(tab) + } + for (const tab of extraTabs) { + add(tab) + } + return merged + } + + private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { + if (tab.type === 'terminal') { + // Why: split terminal leaves share one parent tab; merge dedup must stay + // leaf-scoped or preserved siblings collapse into a single surface. + const keys = [tab.id, `${tab.parentTabId}::${tab.leafId}`] + if (typeof tab.ptyId === 'string' && tab.ptyId.length > 0) { + // Why: renderer and headless sources can derive different leafIds for the same + // terminal; real PTYs collapse those duplicates without merging pending splits. + keys.push(`${tab.parentTabId}::pty:${tab.ptyId}`) + } + return keys + } + if (tab.type === 'browser') { + return [tab.id, tab.browserWorkspaceId] + } + return [tab.id] + } + + private mergeMobileSessionTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + terminalTabs: readonly RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ): RuntimeMobileSessionTabGroup[] { + const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) + if (parentTabOrder.length === 0) { + return [...groups] + } + const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) + const nextGroups = + groups.length > 0 + ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + : [ + { + id: targetGroupId, + activeTabId: null, + tabOrder: [] + } + ] + // Why: keep each tab in the group that already owns it (a multi-group split + // must survive the merge), drop tabs no longer present, and route only + // genuinely-new tabs into the active group — never funnel everything into + // group[0], which duplicated/coalesced tabs that lived in other groups. + const ownerGroupId = new Map() + for (const group of nextGroups) { + for (const tabId of group.tabOrder) { + ownerGroupId.set(tabId, group.id) + } + } + const liveTabIds = new Set(parentTabOrder) + const activeParentId = activeTab?.parentTabId ?? null + const activeGroupId = + (activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id + const retainedOrder = new Map(nextGroups.map((group) => [group.id, []])) + // Why: tabOrder is the canonical user-visible order, so it must survive a republish. + // A materialized idle surface can move to the end of terminalTabs; retaining the + // stored order prevents activation from rotating the tab bar. + const placed = new Set() + for (const group of nextGroups) { + for (const tabId of group.tabOrder) { + if (liveTabIds.has(tabId) && !placed.has(tabId)) { + retainedOrder.get(group.id)?.push(tabId) + placed.add(tabId) + } + } + } + for (const tabId of parentTabOrder) { + if (placed.has(tabId)) { + continue + } + const groupId = ownerGroupId.get(tabId) ?? activeGroupId + retainedOrder.get(groupId)?.push(tabId) + placed.add(tabId) + } + return nextGroups + .map((group) => { + const tabOrder = retainedOrder.get(group.id) ?? [] + const keptActive = + group.activeTabId && + tabOrder.includes(group.activeTabId) && + liveTabIds.has(group.activeTabId) + ? group.activeTabId + : null + return { + ...group, + tabOrder, + activeTabId: + activeParentId && tabOrder.includes(activeParentId) + ? activeParentId + : (keptActive ?? tabOrder[0] ?? null) + } + }) + .filter((group) => group.tabOrder.length > 0) + } + + /** + * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, + * normalizing Pi-compatible titles based on launch or foreground ownership. + */ + private publishPtyBackedMobileSessionTerminal( + worktreeId: string, + pty: RuntimePtyWorktreeRecord, + args: { + tabId: string + leafId: string + title: string | null + activate: boolean + selectIfNoActiveTab?: boolean + startupCwd?: string + viewMode?: 'terminal' | 'chat' + split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } + notify?: boolean + } + ): void { + if ( + !this.isMobileSessionSurfaceMembershipAllowed(worktreeId, args.tabId, args.leafId, pty.ptyId) + ) { + return + } + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent + const title = normalizeCompatibleAgentTitleForOwner( + args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', + ownerAgent, + { ownerIsLaunch: Boolean(pty.launchAgent) } + ) + const existingTab = existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.leafId === args.leafId + ) + // Why: a split inserts into the parent tab's layout, which lives on the + // sibling surface, not this new leaf's (empty) existing surface. + const baseLayout = args.split + ? (existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.leafId === args.split!.splitFromLeafId + )?.parentLayout ?? existingTab?.parentLayout) + : existingTab?.parentLayout + const parentLayout = this.buildMaterializedHeadlessParentLayout( + args.leafId, + pty.ptyId, + baseLayout, + args.split + ) + // Why: a main-side PTY rescue or split publication must not erase the + // host's explicit tab mode before the renderer graph catches up. + const viewMode = + args.viewMode ?? + existingTab?.viewMode ?? + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.viewMode !== undefined + )?.viewMode + const tab: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: `${args.tabId}::${args.leafId}`, + parentTabId: args.tabId, + leafId: args.leafId, + ptyId: pty.ptyId, + title, + ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + ...(viewMode ? { viewMode } : {}), + parentLayout, + isActive: + args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) + } + const existingTabs = (existing?.tabs ?? []).filter( + (candidate) => + !( + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.leafId === args.leafId + ) + ) + const tabs = this.mergeMobileSessionSnapshotTabs( + existingTabs.map((candidate) => ({ + ...candidate, + // Why: the client picks one sibling's parentLayout to render the whole + // tab; a split must update every sibling surface to the new tree, or a + // stale single-leaf sibling makes the client fall back to a default + // direction ("Split Right" renders as down). + ...(args.split && candidate.type === 'terminal' && candidate.parentTabId === args.tabId + ? { parentLayout } + : {}), + isActive: tab.isActive ? false : candidate.isActive + })), + [tab] + ) + const activeTab = + (tab.isActive ? tab : tabs.find((candidate) => candidate.id === existing?.activeTabId)) ?? + tabs.find((candidate) => candidate.isActive) ?? + (args.selectIfNoActiveTab !== false ? tabs[0] : null) ?? + null + const terminalTabs = tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' + ) + const next: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: + existing?.publicationEpoch ?? `headless:pty-backed:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId), + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + worktreeId, + existing?.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, next) + if (args.notify !== false) { + this.notifyMobileSessionTabsChanged(worktreeId) + } + } + + private touchMobileSessionSnapshotsForPty( + ptyId: string, + options: { immediate?: boolean } = {} + ): void { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const hasPtyBackedTab = snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) + if (!hasPtyBackedTab) { + continue + } + this.touchMobileSessionTabsForWorktree(worktreeId, options) + } + } + + private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] { + const worktreeIds: string[] = [] + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const hasPtyBackedTab = snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) + if (hasPtyBackedTab) { + worktreeIds.push(worktreeId) + } + } + return worktreeIds + } + + /** Bump the snapshot version and emit, coalesced unless `immediate`. + * Why the bump: clients gate mirrored snapshots on a strictly increasing + * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ + touchMobileSessionTabsForWorktree( + worktreeId: string, + options: { immediate?: boolean } = {} + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + if (options.immediate) { + // Why: readiness/lifecycle changes are structural and must not wait + // behind the title/status coalescing window. + this.notifyMobileSessionTabsChanged(worktreeId) + return + } + // Why: title/status flips several times a second under spinner-in-title + // agents. Coalesce the emit instead of fanning out every version. + this.scheduleMobileSessionTabsChanged(worktreeId) + } + + /** Republish the workspace snapshot after a pane's hook status changed. + * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ + touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { + const resolved = worktreeId ?? this.getTerminalWorktreeIdForPaneKey(paneKey) + if (!resolved) { + return + } + this.touchMobileSessionTabsForWorktree(resolved) + } + + private mobileSessionSnapshotHasSurface( + worktreeId: string, + parentTabId: string, + leafId: string + ): boolean { + return Boolean( + this.mobileSessionTabsByWorktree + .get(worktreeId) + ?.tabs.some( + (tab) => + tab.type === 'terminal' && tab.parentTabId === parentTabId && tab.leafId === leafId + ) + ) + } + + private isMobileSessionSurfaceMembershipAllowed( + worktreeId: string, + parentTabId: string, + leafId: string, + candidatePtyId: string | null | undefined + ): boolean { + const session = this.store?.getWorkspaceSession?.() + const repoId = getRepoIdFromWorktreeId(worktreeId) + if ( + !hasHostAuthoritativeTerminalMembership(session, worktreeId) && + (session !== undefined || !this.terminalTopologyRevisionByRepoId.has(repoId)) + ) { + return true + } + if (this.mobileSessionSnapshotHasSurface(worktreeId, parentTabId, leafId)) { + return true + } + if (!candidatePtyId) { + return false + } + const pty = this.ptysById.get(candidatePtyId) + const pane = parsePaneKey(pty?.paneKey ?? '') + return Boolean( + pty?.connected && + pty.worktreeId === worktreeId && + pty.tabId === parentTabId && + pane?.leafId === leafId + ) + } + + private reconcileMobileSessionRetirementFences( + leaves: readonly RuntimeSyncedLeaf[] + ): RuntimeSyncedLeaf[] { + return leaves.filter((leaf) => + this.isMobileSessionSurfaceMembershipAllowed( + leaf.worktreeId, + leaf.tabId, + leaf.leafId, + leaf.ptyId + ) + ) + } + + private applyMobileSessionRetirementFences( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let next = snapshot + for (const tab of snapshot.tabs) { + if ( + tab.type !== 'terminal' || + this.isMobileSessionSurfaceMembershipAllowed( + snapshot.worktree, + tab.parentTabId, + tab.leafId, + tab.ptyId + ) + ) { + continue + } + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot: next, + ptyId: tab.ptyId ?? '', + exactSurfaces: [{ parentTabId: tab.parentTabId, leafId: tab.leafId }], + exactOnly: true + }) + if (retired) { + next = retired.snapshot + } + } + return next + } + + /** + * Retires each surface in the session partition of the host that owns its worktree. + * Why: an SSH pane's durable surface lives in that connection's partition; retiring it + * against the local partition strands the real ghost and bumps a foreign host's epoch. + * Returns null when nothing may be published because persistence is unavailable or failed. + */ + private persistTerminalSurfaceRetirements( + retiredSurfaces: readonly RetiredTerminalSurface[] + ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { + const surfacesByHostId = new Map() + for (const surface of retiredSurfaces) { + const hostId = + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID + const bucket = surfacesByHostId.get(hostId) + if (bucket) { + bucket.push(surface) + } else { + surfacesByHostId.set(hostId, [surface]) + } + } + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] + const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] + const originalSessions = new Map() + const stagedSessions = new Map() + for (const [hostId, surfaces] of surfacesByHostId) { + const session = this.store?.getWorkspaceSession?.(hostId) + if (!session) { + unpersisted.push(...surfaces) + continue + } + // Why: publishing absence before its host membership fence is durable lets a crash or + // stale renderer write resurrect the retired surface. + if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + return null + } + originalSessions.set(hostId, session) + let nextSession = session + const acceptedForHost: RetiredTerminalSurface[] = [] + for (const surface of surfaces) { + const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) + if (candidate !== nextSession) { + acceptedForHost.push(surface) + nextSession = candidate + } + } + if (acceptedForHost.length === 0) { + continue + } + accepted.push(...acceptedForHost) + pendingWrites.push({ hostId, session: nextSession }) + } + if (pendingWrites.length > 0) { + try { + for (const write of pendingWrites) { + this.store?.setWorkspaceSession?.(write.session, write.hostId) + const staged = this.store?.getWorkspaceSession?.(write.hostId) + if (staged) { + stagedSessions.set(write.hostId, staged) + } + } + this.store?.flushOrThrow?.() + } catch (error) { + // setWorkspaceSession mutates the in-memory partition before the flush. Restore only + // fields still equal to our staged write so concurrent renderer updates survive. + for (const [hostId, original] of originalSessions) { + const staged = stagedSessions.get(hostId) + const current = this.store?.getWorkspaceSession?.(hostId) + if (!staged || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + original, + staged, + current + ) + if (rolledBack !== current) { + this.store?.setWorkspaceSession?.(rolledBack, hostId) + } + } + console.error('[runtime] failed to persist terminal retirement:', error) + return null + } + } + return { accepted, unpersisted } + } + + private retireMobileSessionSurfacesForPty( + ptyId: string, + incarnationId: string, + exactSurfaces: readonly Pick[] + ): void { + const terminalHandle = + this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined + const retiredSurfaceByKey = new Map() + for (const surface of exactSurfaces) { + retiredSurfaceByKey.set(`${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, { + ...surface, + ptyId, + incarnationId + }) + } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: exactSurfaces.filter((surface) => surface.worktreeId === worktreeId) + }) + if (!retired) { + continue + } + for (const surface of retired.retired) { + retiredSurfaceByKey.set( + `${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, + { ...surface, incarnationId } + ) + } + } + const retiredSurfaces = [...retiredSurfaceByKey.values()] + if (retiredSurfaces.length === 0) { + return + } + const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) + if (!persisted) { + return + } + for (const surface of persisted.unpersisted) { + const repoId = getRepoIdFromWorktreeId(surface.worktreeId) + this.terminalTopologyRevisionByRepoId.set( + repoId, + (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 + ) + } + // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. + const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] + if (publishableRetiredSurfaces.length === 0) { + return + } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: publishableRetiredSurfaces.filter( + (surface) => surface.worktreeId === worktreeId + ), + // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. + exactOnly: true, + ...(terminalHandle + ? { + retirementProofs: publishableRetiredSurfaces + .filter((surface) => surface.worktreeId === worktreeId) + .map((surface) => ({ + parentTabId: surface.parentTabId, + leafId: surface.leafId, + ptyId: surface.ptyId, + terminal: terminalHandle, + incarnationId + })) + } + : {}) + }) + if (retired) { + this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot) + this.notifyMobileSessionTabsChanged(worktreeId) + } + } + } + + private buildHeadlessMobileSessionTerminalTabs( + worktreeId: string, + persistedTabs: readonly TerminalTab[], + session: WorkspaceSessionState + ): RuntimeMobileSessionTerminalTab[] { + return [...persistedTabs] + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .flatMap((tab, index) => { + const layout = session.terminalLayoutsByTabId?.[tab.id] + const leafIds = this.collectPersistedTerminalLeafIds(layout) + if (leafIds.length === 0) { + leafIds.push(this.deriveHeadlessLegacyTerminalLeafId(tab.id)) + } + return leafIds.flatMap((leafId) => { + const ptyId = + layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) + const title = + tab.customTitle?.trim() || + tab.generatedTitle?.trim() || + tab.title?.trim() || + tab.defaultTitle?.trim() || + `Terminal ${index + 1}` + return [ + { + type: 'terminal' as const, + id: `${tab.id}::${leafId}`, + parentTabId: tab.id, + leafId, + title, + ...(ptyId ? { ptyId } : {}), + ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), + ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), + ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), + ...(tab.color != null ? { color: tab.color } : {}), + ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), + isActive: this.isPersistedTerminalLeafActive( + session, + worktreeId, + tab.id, + leafId, + layout + ) + } + ] + }) + }) + } + + // Why: headless serve backs browser panes with offscreen WebContents that live + // only in the BrowserManager, never in a renderer graph. Without surfacing them + // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the + // paired browser tab and closing it fails with tab_not_found. Synthesize browser + // session tabs from the live bridge so they are first-class alongside terminals. + private buildHeadlessMobileSessionBrowserTabs( + worktreeId: string + ): RuntimeMobileSessionBrowserTab[] { + const serverTabs = + this.offscreenBrowserBackend && this.agentBrowserBridge?.tabList + ? this.agentBrowserBridge.tabList(worktreeId).tabs + : [] + const publishedServerTabs = serverTabs.map((tab) => { + const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId) + return { + type: 'browser' as const, + // Why: an offscreen page has no separate workspace identity, so the page id + // is its own workspace id (matches the server's browserWorkspaceId fallback). + id: tab.browserPageId, + title: tab.title || tab.url || 'Browser', + browserWorkspaceId: tab.browserPageId, + browserPageId: tab.browserPageId, + url: tab.url || 'about:blank', + loading: false, + canGoBack: false, + canGoForward: false, + loadError: tab.loadError ?? undefined, + certificateFailure: tab.certificateFailure ?? undefined, + ...(persistedProps ? { color: persistedProps.color } : {}), + ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), + isActive: tab.active === true + } + }) + const publishedClientTabs = getRuntimeBrowserPageRegistry(this) + .listPages(worktreeId) + .map((page) => ({ + type: 'browser' as const, + id: page.browserPageId, + title: page.title || page.url || 'Browser', + browserWorkspaceId: page.browserPageId, + browserPageId: page.browserPageId, + browserProfileId: page.browserProfileId, + executionHostKey: page.executionHostKey, + placement: page.placement, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + isActive: page.active + })) + return [...publishedServerTabs, ...publishedClientTabs] + } + + // Why: change detection for headless browser tabs. Compares the fields that + // actually vary (a JSON.stringify equality was order-sensitive and silently + // dropped `undefined` keys, so it only worked while both sides shared one + // construction path). + private headlessBrowserTabsUnchanged( + live: RuntimeMobileSessionBrowserTab[], + existing: RuntimeMobileSessionBrowserTab[] + ): boolean { + if (live.length !== existing.length) { + return false + } + return live.every((tab, index) => { + const prev = existing[index] + return ( + tab.id === prev.id && + tab.title === prev.title && + tab.url === prev.url && + tab.loading === prev.loading && + tab.canGoBack === prev.canGoBack && + tab.canGoForward === prev.canGoForward && + tab.browserProfileId === prev.browserProfileId && + tab.executionHostKey === prev.executionHostKey && + ((tab.placement === undefined && prev.placement === undefined) || + (tab.placement !== undefined && + prev.placement !== undefined && + sameRuntimeBrowserPlacement(tab.placement, prev.placement))) && + tab.isActive === prev.isActive && + (tab.isPinned ?? false) === (prev.isPinned ?? false) && + (tab.color ?? null) === (prev.color ?? null) && + this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && + this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) + ) + }) + } + + private browserLoadErrorsEqual( + a: RuntimeMobileSessionBrowserTab['loadError'], + b: RuntimeMobileSessionBrowserTab['loadError'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.code === right.code && + left.description === right.description && + left.validatedUrl === right.validatedUrl + ) + } + + private browserCertificateFailuresEqual( + a: RuntimeMobileSessionBrowserTab['certificateFailure'], + b: RuntimeMobileSessionBrowserTab['certificateFailure'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) + } + + private getPersistedUnifiedSessionTabProps( + worktreeId: string, + tabId: string + ): Pick | null { + const tab = + this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find( + (candidate) => candidate.id === tabId || candidate.entityId === tabId + ) ?? null + return tab ? { color: tab.color, isPinned: tab.isPinned } : null + } + + private collectPersistedTerminalLeafIds(layout: TerminalLayoutSnapshot | undefined): string[] { + if (!layout) { + return [] + } + const leafIds = new Set() + const visit = (node: TerminalLayoutSnapshot['root']): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } + return + } + visit(node.first) + visit(node.second) + } + visit(layout.root) + if (layout.activeLeafId && isTerminalLeafId(layout.activeLeafId)) { + leafIds.add(layout.activeLeafId) + } + for (const leafId of Object.keys(layout.ptyIdsByLeafId ?? {})) { + if (isTerminalLeafId(leafId)) { + leafIds.add(leafId) + } + } + return [...leafIds] + } + + private deriveHeadlessLegacyTerminalLeafId(tabId: string): string { + const hash = createHash('sha256').update(`headless-terminal-leaf:${tabId}`).digest('hex') + const variant = ((Number.parseInt(hash.slice(16, 17), 16) & 0x3) | 0x8).toString(16) + const leafId = [ + hash.slice(0, 8), + hash.slice(8, 12), + `4${hash.slice(13, 16)}`, + `${variant}${hash.slice(17, 20)}`, + hash.slice(20, 32) + ].join('-') + if (!isTerminalLeafId(leafId)) { + return randomUUID() + } + return leafId + } + + private cloneTerminalLayoutSnapshot(layout: TerminalLayoutSnapshot): TerminalLayoutSnapshot { + const cloned: TerminalLayoutSnapshot = { + root: layout.root, + activeLeafId: layout.activeLeafId, + expandedLeafId: layout.expandedLeafId + } + if (layout.ptyIdsByLeafId) { + cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } + } + if (layout.buffersByLeafId) { + cloned.buffersByLeafId = { ...layout.buffersByLeafId } + } + if (layout.scrollbackRefsByLeafId) { + cloned.scrollbackRefsByLeafId = { ...layout.scrollbackRefsByLeafId } + } + if (layout.titlesByLeafId) { + cloned.titlesByLeafId = { ...layout.titlesByLeafId } + } + return cloned + } + + private isPersistedTerminalLeafActive( + session: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string, + layout: TerminalLayoutSnapshot | undefined + ): boolean { + const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId + return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId) + } + + private pickHeadlessActiveTerminalTab( + tabs: readonly RuntimeMobileSessionTerminalTab[] + ): RuntimeMobileSessionTerminalTab | null { + return tabs.find((tab) => tab.isActive) ?? tabs.find((tab) => tab.parentTabId) ?? null + } + + private collectHeadlessParentTabOrder( + tabs: readonly RuntimeMobileSessionTerminalTab[] + ): string[] { + const order: string[] = [] + const seen = new Set() + for (const tab of tabs) { + if (!seen.has(tab.parentTabId)) { + seen.add(tab.parentTabId) + order.push(tab.parentTabId) + } + } + return order + } + + // Why: the group tab order must follow actual creation/insertion order across + // both terminals and browsers, not list terminals first. A terminal's top-level + // id is its parentTabId (split leaves share one); a browser's is its own id. + private collectHeadlessTopLevelTabOrder( + tabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string[] { + const order: string[] = [] + const seen = new Set() + for (const tab of tabs) { + const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id + if (!seen.has(topLevelId)) { + seen.add(topLevelId) + order.push(topLevelId) + } + } + return order + } + + private getHeadlessMobileSessionGroupId(worktreeId: string): string { + return `headless-terminals:${worktreeId}` + } + + private buildHeadlessMobileSessionTabGroups( + worktreeId: string, + tabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTab: RuntimeMobileSessionSnapshotTab | null, + existingGroups?: readonly RuntimeMobileSessionTabGroup[], + // Why: a new tab created via a specific group's "+" must land in THAT group, + // not the active one — otherwise every "+" in a split funnels to one group. + newTabAssignment?: { tabId: string; groupId: string } + ): RuntimeMobileSessionTabGroup[] { + // Why: order across terminals and browsers in their actual array order so a + // tab opened after a browser tab lands to its right, not regrouped before it. + const arrivalOrder = this.collectHeadlessTopLevelTabOrder(tabs) + // Why: tabOrder is the user-visible order and must survive a republish. A + // materialized idle surface can move to the end of the incoming array, so + // retain stored positions and append only genuinely new ids. + const liveTopLevelIds = new Set(arrivalOrder) + const tabOrder: string[] = [] + const placed = new Set() + for (const group of existingGroups ?? []) { + for (const tabId of group.tabOrder) { + if (liveTopLevelIds.has(tabId) && !placed.has(tabId)) { + tabOrder.push(tabId) + placed.add(tabId) + } + } + } + for (const tabId of arrivalOrder) { + if (!placed.has(tabId)) { + tabOrder.push(tabId) + placed.add(tabId) + } + } + const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string => + tab.type === 'terminal' ? tab.parentTabId : tab.id + const activeTopLevelId = + (activeTab ? topLevelOf(activeTab) : null) ?? + existingGroups?.[0]?.activeTabId ?? + (() => { + const active = tabs.find((tab) => tab.isActive) + return active ? topLevelOf(active) : null + })() ?? + tabOrder[0] ?? + null + + // Why: when the user has split tabs into multiple groups, preserve that + // assignment across rebuilds instead of coalescing back to one group. + if (existingGroups && existingGroups.length > 1) { + return this.distributeHeadlessTabsAcrossGroups( + existingGroups, + tabOrder, + activeTopLevelId, + newTabAssignment + ) + } + + const groupId = existingGroups?.[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) + return [ + { + id: groupId, + activeTabId: + activeTopLevelId && tabOrder.includes(activeTopLevelId) + ? activeTopLevelId + : (tabOrder[0] ?? null), + tabOrder + } + ] + } + + // Distribute live top-level tabs into the existing multi-group structure, + // keeping each tab in its group; tabs new since the last snapshot join the + // active group. Emptied groups are dropped so a closed split collapses. + private distributeHeadlessTabsAcrossGroups( + existingGroups: readonly RuntimeMobileSessionTabGroup[], + tabOrder: readonly string[], + activeTopLevelId: string | null, + newTabAssignment?: { tabId: string; groupId: string } + ): RuntimeMobileSessionTabGroup[] { + const groupIdByTabId = new Map() + for (const group of existingGroups) { + for (const tabId of group.tabOrder) { + groupIdByTabId.set(tabId, group.id) + } + } + // Why: route a freshly-created tab to the group its "+" was clicked in, + // when that group still exists; otherwise fall through to the active group. + const hasTargetGroup = + newTabAssignment !== undefined && + existingGroups.some((group) => group.id === newTabAssignment.groupId) + if (hasTargetGroup) { + groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId) + } + const activeGroupId = + (activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id + const orderByGroup = new Map(existingGroups.map((group) => [group.id, []])) + for (const tabId of tabOrder) { + const groupId = groupIdByTabId.get(tabId) ?? activeGroupId + orderByGroup.get(groupId)?.push(tabId) + } + return existingGroups + .map((group) => { + const nextOrder = orderByGroup.get(group.id) ?? [] + return { + ...group, + tabOrder: nextOrder, + activeTabId: + activeTopLevelId && nextOrder.includes(activeTopLevelId) + ? activeTopLevelId + : group.activeTabId && nextOrder.includes(group.activeTabId) + ? group.activeTabId + : (nextOrder[0] ?? null) + } + }) + .filter((group) => group.tabOrder.length > 0) + } + + private buildMaterializedHeadlessParentLayout( + leafId: string, + ptyId: string, + existingLayout: TerminalLayoutSnapshot | undefined, + split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } + ): TerminalLayoutSnapshot { + if (!existingLayout) { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + } + // Why: a split must insert the new leaf into the live layout tree with the + // requested direction, or the published snapshot keeps the old single-leaf + // root and the split renders with a fallback direction ("Split Right" lands + // as a top/bottom split). Reuse the persisted-split builder for parity. + if (split) { + return buildHeadlessTerminalSplitLayout(this.cloneTerminalLayoutSnapshot(existingLayout), { + leafId, + ptyId, + splitFromLeafId: split.splitFromLeafId, + direction: split.direction + }) + } + return { + ...this.cloneTerminalLayoutSnapshot(existingLayout), + ptyIdsByLeafId: { + ...existingLayout.ptyIdsByLeafId, + [leafId]: ptyId + } + } + } + + private commitHeadlessTerminalTabRetirement( + worktreeId: string, + parentTabId: string, + options: { allowMissing?: boolean } = {} + ): string[] { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + throw new Error('workspace_session_unavailable') + } + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) + if (result.pinned) { + throw new Error('terminal_tab_pinned') + } + if (!result.closed) { + if (!options.allowMissing) { + throw new Error('tab_not_found') + } + } + const persisted = result.closed + ? advanceTerminalTopologyRevision(result.session, worktreeId) + : session + this.setWorkspaceSessionForWorktree(worktreeId, persisted) + const staged = this.getWorkspaceSessionForWorktree(worktreeId) + try { + this.store.flushOrThrow() + } catch (error) { + const current = this.getWorkspaceSessionForWorktree(worktreeId) + if (staged && current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) + } + } + throw error + } + return result.ptyIdsToKill + } + + private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return + } + const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) + const tabs = session.tabsByWorktree[worktreeId] ?? [] + const reordered = [...tabs] + .sort((a, b) => { + const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER + const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER + return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt + }) + .map((tab, index) => ({ + ...tab, + sortOrder: index + })) + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: reordered + } + }) + } + + private emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) + } + } + + /** + * Answers one client's session-tabs question: whether this runtime has taken back *that* client's + * client-hosted pages yet, then that client's own tab selection. + * + * The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a + * frame built for one client can never carry another client's answer. + */ + private projectMobileSessionTabsForClient( + result: RuntimeMobileSessionTabsResult, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + return this.clientSessionTabSelections.project( + this.withClientHostedPagesHold(result, clientNavigationId), + clientNavigationId + ) + } + + private withClientHostedPagesHold( + result: RuntimeMobileSessionTabsResult, + clientNavigationId: string | undefined + ): RuntimeMobileSessionTabsResult { + return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) + } + + private async refreshMobileSessionPtyRecords( + targetWorktreeId: string | null = null + ): Promise | null> { + const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId) + return inventory ? new Set(inventory.livePtyIds) : null + } + + private async refreshMobileSessionPtyInventory( + targetWorktreeId: string | null = null + ): Promise { + // Targeted mobile polls must not queue behind an aggregate census that may + // be waiting on an unrelated SSH provider. + if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + } + if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + // Fleet-wide refreshes share one aggregate controller inventory. + const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh + if (pending) { + return pending + } + // Why: reconnect exit bursts share one authoritative daemon inventory + // instead of multiplying a full cross-generation list RPC per stale tab. + const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { + if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) { + this.pendingMobileSessionPtyAggregateInventoryRefresh = null + } + }) + this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh + return refresh + } + return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + } + + private async performMobileSessionPtyRecordsRefresh( + targetWorktreeId: string | null + ): Promise { + if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { + return null + } + // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. + const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID + const resolvedWorktrees = isFloatingWorkspace + ? [] + : targetWorktreeId + ? this.listResolvedWorktreesForExplicitTarget(targetWorktreeId) + : await this.listResolvedWorktrees() + // An explicit mobile worktree belongs to one execution host. Query only + // that provider; aggregate inventory would wait on unrelated SSH hosts. + const targetExecutionHost = targetWorktreeId + ? (resolvedWorktrees.find((worktree) => worktree.id === targetWorktreeId)?.hostId ?? + this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + const parsedTargetHost = targetExecutionHost ? parseExecutionHostId(targetExecutionHost) : null + // Paired/runtime-owned workspaces have a separate controller; this runtime + // cannot inspect them and must not silently query its local PTY provider. + if (parsedTargetHost?.kind === 'runtime') { + return null + } + const targetConnectionId = + parsedTargetHost?.kind === 'ssh' + ? parsedTargetHost.targetId + : targetWorktreeId + ? null + : undefined + return await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId, + undefined, + targetConnectionId + ) + } + + /** Targeted mobile opens must not wait for an unrelated SSH/Git worktree scan. */ + private listResolvedWorktreesForExplicitTarget(targetWorktreeId: string): ResolvedWorktree[] { + const cached = + this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now() + ? this.resolvedWorktreeCache.worktrees + : null + const targetWorktree = + cached?.find((worktree) => worktree.id === targetWorktreeId) ?? + (() => { + const scope = parseWorkspaceKey(targetWorktreeId) + if (scope?.type === 'folder') { + const folder = this.store + ?.getFolderWorkspaces?.() + .find((workspace) => workspace.id === scope.folderWorkspaceId) + return folder ? this.folderWorkspaceToResolvedWorktree(folder) : null + } + return this.buildResolvedWorktreeFromId(targetWorktreeId) + })() + if (!targetWorktree) { + return [] + } + return cached + ? includeTargetResolvedWorktree(cached, targetWorktree) + : this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) + } + + async activateMobileSessionTab( + worktreeSelector: string, + tabId: string, + leafId?: string, + opts: { + notifyClients?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget + intent?: TabActivationIntent + } = {} + ): Promise { + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + await this.refreshMobileSessionPtyRecords(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) + const tab = leafId + ? ((directTab?.type === 'terminal' && directTab.leafId === leafId ? directTab : undefined) ?? + snapshot?.tabs.find( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === tabId && + candidate.leafId === leafId + )) + : (directTab ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId + ) ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId + )) + if (!snapshot || !tab) { + throw new Error('tab_not_found') + } + + if (tab.type === 'terminal') { + const publicTab = this.toMobileSessionTabsResult(snapshot).tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.id === tab.id + ) + // Why: serve-created tabs can be visible before any renderer has adopted + // their tab id, so focusing the renderer would silently no-op. + // Phone-local activation also needs this path for inactive restored tabs: + // desktop focus is intentionally suppressed, but the PTY still must exist. + const shouldMaterializePendingTerminal = + publicTab?.type === 'terminal' && + publicTab.status !== 'ready' && + // Why: opening a tab is the documented wake gesture for a slept pane + // (#11598), so only a background probe may be refused for one. + (!isAutomaticTabActivation(opts.intent) || + !this.isDeliberatelyParkedPane(worktreeId, tab)) && + (!targetsHost || + !this.notifier?.focusTerminal || + this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab)) + if (shouldMaterializePendingTerminal) { + const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined + const targetGroupId = snapshot?.tabGroups?.find((group) => + group.tabOrder.includes(tab.parentTabId) + )?.id + // Why: a pending agent tab may exist without its startup command ever + // having been delivered (the create's renderer stalled, #7587), so a + // bare materialize would put a plain shell under the agent icon. + // Re-resolve the launch like the create path; providers skip startup + // commands when attaching to live sessions, so this cannot double-launch. + let agentStartup: Awaited< + ReturnType + > = {} + if (tab.launchAgent) { + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, { + agent: tab.launchAgent + }) + } catch { + // Why: a disabled or unresolvable agent must not make the tab + // untappable; fall back to the plain-shell materialize. + } + } + try { + await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, { + identity: { + tabId: tab.parentTabId, + leafId: tab.leafId, + sessionId + }, + cwd: tab.startupCwd, + command: agentStartup.command, + env: agentStartup.env, + startupCommandDelivery: agentStartup.startupCommandDelivery, + launchConfig: agentStartup.launchConfig, + launchAgent: tab.launchAgent, + targetGroupId + }) + } catch (err) { + if (sessionId && parseAppSshPtyId(sessionId)) { + // Why: an expired SSH reattach clears durable bindings in the store, + // but this in-memory headless snapshot can still carry the old id. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) + } + throw err + } + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) + } + const callerSnapshot = this.getMobileSessionTabsForWorktree( + worktreeId, + opts.clientNavigationId + ) + const activeSibling = + tab.id === tabId || leafId + ? null + : (callerSnapshot.tabs.find( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === tab.parentTabId && + candidate.isActive + ) as RuntimeMobileSessionTerminalTab | undefined) + const targetTab = activeSibling ?? tab + if (targetsHost && !this.notifier?.focusTerminal) { + if ( + !targetTab.isActive && + this.shouldPersistHeadlessMobileSessionActivation(snapshot, targetTab) + ) { + this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot, targetTab) + } + } else if (targetsHost) { + this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId) + } + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + targetTab.id, + navigation, + opts.clientNavigationId + ) + } else if (tab.type === 'browser') { + // Why: browser mobile tabs are renderer-owned unified tabs; focusing the + // session tab keeps desktop tab order/group state authoritative. + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) + } + } else { + if (targetsHost) { + this.notifier?.focusEditorTab?.(tab.id, worktreeId) + } + } + return this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + navigation, + opts.clientNavigationId + ) + } + + private applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + let callerSnapshot: RuntimeMobileSessionTabsResult | null = null + if (navigationTargetsClients(navigation)) { + // Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect. + const ids = new Set( + [...this.mobileSessionTabListeners] + .map((subscription) => subscription.clientNavigationId) + .filter((id): id is string => Boolean(id)) + ) + if (clientNavigationId) { + ids.add(clientNavigationId) + } + for (const id of ids) { + const projected = this.clientSessionTabSelections.activate( + this.withClientHostedPagesHold(snapshot, id), + id, + activeTabId + ) + this.emitMobileSessionTabsSnapshotToClient(projected, id, true) + if (id === clientNavigationId) { + callerSnapshot = projected + } + } + } else if (clientNavigationId) { + // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. + callerSnapshot = this.clientSessionTabSelections.activate( + this.withClientHostedPagesHold(snapshot, clientNavigationId), + clientNavigationId, + activeTabId + ) + this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId) + } + if (clientNavigationId) { + return callerSnapshot ?? this.projectMobileSessionTabsForClient(snapshot, clientNavigationId) + } + if (navigation === 'caller') { + const selection = activateClientSessionTabSelection( + snapshot, + deriveClientSessionTabSelection(snapshot), + activeTabId + ) + return projectClientSessionTabSelection(snapshot, selection).snapshot + } + return snapshot + } + + /** + * Whether persistence proves this pane's PTY was deliberately taken down and parked + * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. + * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it + * indefinitely — and respawning a parked pane re-launches its agent behind the user. + * Only an automatic activation consults this; a user opening the tab is the wake gesture. + */ + private isDeliberatelyParkedPane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const record = + this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ + makePaneKey(tab.parentTabId, tab.leafId) + ] + // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect + // must still mint its replacement PTY (#11542). Only a worktree-owned capture records + // a deliberate takedown the user did not ask to undo. + return record?.origin === 'worktree-sleep' && worktreeIdsEqual(record.worktreeId, worktreeId) + } + + private shouldMaterializeHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOrSshOwnedBinding(tab) + ) + } + + private shouldPersistHeadlessMobileSessionActivation( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + if (snapshot.publicationEpoch.includes(':headless-merge:')) { + return false + } + if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') { + return false + } + return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab) + } + + private activateHeadlessMobileSessionTerminalTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + activeTab: RuntimeMobileSessionTerminalTab + ): void { + const tabs = snapshot.tabs.map((candidate) => ({ + ...candidate, + isActive: candidate.id === activeTab.id + })) + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: activeTab.id, + activeTabType: 'terminal', + tabGroups: this.buildHeadlessMobileSessionTabGroups( + worktreeId, + tabs, + activeTab, + snapshot.tabGroups + ), + tabs + } + this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + // Why: a headless split only updated the LIVE session snapshot, never the + // persisted workspace session layout. So a later snapshot rebuild (e.g. on the + // next terminal create) re-derived from the stale single-leaf persisted layout + // and collapsed the split. Persist the new split leaf into the workspace + // session's terminalLayoutsByTabId so the split survives rebuilds. + private persistHeadlessTerminalSplit(args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + splitFromLeafId: string + direction: 'horizontal' | 'vertical' + }): boolean { + const session = this.getWorkspaceSessionForWorktree(args.worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return false + } + const existing = session.terminalLayoutsByTabId?.[args.tabId] + const nextLayout = buildHeadlessTerminalSplitLayout( + existing ? this.cloneTerminalLayoutSnapshot(existing) : undefined, + args + ) + this.setWorkspaceSessionForWorktree(args.worktreeId, { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: nextLayout + } + }) + return true + } + + private persistHeadlessTerminalActiveLeaf( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): void { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return + } + const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId] + const nextLayouts = existingLayout + ? { + ...session.terminalLayoutsByTabId, + [tab.parentTabId]: { + ...this.cloneTerminalLayoutSnapshot(existingLayout), + activeLeafId: tab.leafId + } + } + : session.terminalLayoutsByTabId + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + activeTabId: tab.parentTabId, + activeTabIdByWorktree: { + ...session.activeTabIdByWorktree, + [worktreeId]: tab.parentTabId + }, + terminalLayoutsByTabId: nextLayouts + }) + } + + async refuseUnattributedMobileSessionTabClose( + worktreeSelector: string, + tabId: string + ): Promise { + const snapshot = await this.listMobileSessionTabs(worktreeSelector) + const tabExists = snapshot.tabs.some( + (candidate) => + candidate.id === tabId || + (candidate.type === 'terminal' && candidate.parentTabId === tabId) || + (candidate.type === 'browser' && candidate.browserWorkspaceId === tabId) + ) + if (!tabExists) { + throw new Error('tab_not_found') + } + // Why: a legacy client may already have hidden its mirror; a new snapshot + // restores it without granting an unattributed request destructive authority. + this.republishMobileSessionTabsSnapshot(snapshot.worktree) + return refusedMobileSessionTabClose('missing-intent', { + snapshotRepublished: true + }) + } + + async closeMobileSessionTab( + worktreeSelector: string, + tabId: string, + options: { + reason?: RuntimeSessionTabCloseReason + expectedPublicationEpoch?: string + expectedTerminalHandle?: string + clientNavigationId?: string + localPtyTeardownOwnedExternally?: boolean + expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority + } = {} + ): Promise { + const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + const observedPtyIds = await this.refreshMobileSessionPtyRecords() + if (graphEpoch !== null) { + this.assertStableReadyGraph(graphEpoch) + } + this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) { + // Why: keep-on-unknown must also restore the mirror the caller already pruned. + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('unknown-liveness', { + snapshotRepublished: Boolean(snapshot) + }) + } + if ( + options.expectedPublicationEpoch !== undefined && + snapshot?.publicationEpoch !== options.expectedPublicationEpoch + ) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('stale-publication', { + snapshotRepublished: Boolean(snapshot) + }) + } + const ptyCloseAuthority = options.expectedPtyCloseAuthority + ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) + : null + const tab = options.expectedPtyCloseAuthority + ? ptyCloseAuthority?.surface.tab + : (snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId + ) ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId + )) + const lifecycleCloseParentTabId = + tab?.type === 'terminal' + ? tab.parentTabId + : ptyCloseAuthority?.surface.tab.type === 'terminal' + ? ptyCloseAuthority.surface.tab.parentTabId + : this.tabs.has(tabId) + ? tabId + : ([...this.tabs.keys()] + .filter((parentTabId) => tabId.startsWith(`${parentTabId}::`)) + .sort((a, b) => b.length - a.length)[0] ?? + ( + snapshot?.tabs.find( + (candidate) => + candidate.type === 'terminal' && tabId.startsWith(`${candidate.parentTabId}::`) + ) as RuntimeMobileSessionTerminalTab | undefined + )?.parentTabId ?? + null) + const lifecycleParentLeaves = lifecycleCloseParentTabId + ? (snapshot?.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === lifecycleCloseParentTabId + ) ?? []) + : [] + const lifecycleRendererLeaves = lifecycleCloseParentTabId + ? [...this.leaves.values()].filter( + (leaf) => + leaf.tabId === lifecycleCloseParentTabId && + worktreeIdsEqual(leaf.worktreeId, worktreeId) + ) + : [] + const lifecycleLeafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { + const snapshotPtyIds = [leaf.ptyId, leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId]].filter( + (ptyId): ptyId is string => Boolean(ptyId) + ) + return ( + this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true || + snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true) + ) + } + const lifecycleRendererLeafHasConnectedPty = (leaf: RuntimeLeafRecord): boolean => { + const ptyId = leaf.ptyId + return Boolean( + ptyId && + (this.ptysById.get(ptyId)?.connected === true || observedPtyIds?.has(ptyId) === true) + ) + } + const lifecycleCloseLeafId = + lifecycleCloseParentTabId && tabId.startsWith(`${lifecycleCloseParentTabId}::`) + ? tabId.slice(lifecycleCloseParentTabId.length + 2) + : null + if (!snapshot || !tab) { + // Lifecycle echoes are idempotent: a provider exit may have already + // retired the surface before the viewer reports its stale close. A user + // close still fails closed so an unknown target cannot be hidden. + if (options.reason !== undefined && options.reason !== 'user') { + // A missing leaf can still be part of a live split parent. Closing that + // parent would take the surviving sibling down, so retain the refusal + // even though the addressed leaf has already been retired. + const hasLiveRendererParentLeaf = lifecycleRendererLeaves.some( + lifecycleRendererLeafHasConnectedPty + ) + if (lifecycleParentLeaves.some(lifecycleLeafHasConnectedPty) || hasLiveRendererParentLeaf) { + const addressedDeadRendererLeaf = + lifecycleCloseLeafId !== null && + !lifecycleRendererLeaves.some( + (leaf) => + leaf.leafId === lifecycleCloseLeafId && lifecycleRendererLeafHasConnectedPty(leaf) + ) + if (addressedDeadRendererLeaf) { + return refusedMobileSessionTabClose('live-host-pty') + } + if (snapshot) { + this.republishMobileSessionTabsSnapshot(worktreeId) + } + return refusedMobileSessionTabClose('live-host-pty') + } + // The renderer owns a graph-visible parent, including a dead leaf whose + // lifecycle echo arrived after main retired its mirror. Leave retirement + // to that renderer instead of acknowledging a host-side close. + if (lifecycleCloseParentTabId && this.tabs.has(lifecycleCloseParentTabId)) { + return refusedMobileSessionTabClose('retirement-owner') + } + return delegatedMobileSessionTabClose() + } + throw new Error(options.expectedPtyCloseAuthority ? 'terminal_handle_stale' : 'tab_not_found') + } + if (options.expectedTerminalHandle !== undefined) { + const terminalIncarnationMatches = + tab.type === 'terminal' && + snapshot.tabs.some( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === tab.parentTabId && + this.getMobileSessionTerminalHandle(worktreeId, candidate) === + options.expectedTerminalHandle + ) + if (!terminalIncarnationMatches) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('stale-terminal', { + snapshotRepublished: true + }) + } + } + let closedSelectionTabIds = [tab.id] + const finishCommittedClose = (): MobileSessionTabCloseOutcome => + committedMobileSessionTabClose( + this.clientSessionTabSelections, + worktreeId, + closedSelectionTabIds + ) + if (tab.type === 'terminal') { + const parentLeafCount = snapshot.tabs.filter( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ).length + const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + if (closingWholeParent) { + closedSelectionTabIds = snapshot.tabs.flatMap((candidate) => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ? [candidate.id, candidate.parentTabId] + : [] + ) + } + // Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"), + // not authorization to kill. Every destructive branch below can take the + // whole parent down, so any live PTY under the parent means the echo is a + // transport artifact: refuse the close and republish the snapshot so the + // echoing client re-syncs and re-attaches. A reasonless close keeps + // legacy behavior — old clients send user closes without the field. + if (options.reason !== undefined && options.reason !== 'user') { + const parentLeaves = snapshot.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ) + // Why: exited PTYs keep a disconnected record in ptysById for status + // reads (and a still-synced leaf retains its record), so record + // presence is not liveness — only `connected` counts, or a genuinely + // dead tab never retires and the echo loops forever. + const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { + return lifecycleLeafHasConnectedPty(leaf) + } + if (parentLeaves.some(leafHasConnectedPty)) { + // Why: when the echo addresses a dead leaf under a live sibling we + // still refuse (every reachable close path below destroys the whole + // parent, live sibling included) but skip the republish — re-adding + // the dead leaf on the echoing client would feed an endless + // refuse→republish→re-echo cycle. + const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab) + if (!addressedDeadLeaf) { + this.republishMobileSessionTabsSnapshot(worktreeId) + } + // Why: both markers are skew-safe; clients must restore a mirror only + // when the host actually republished it, not for a dead leaf. + return refusedMobileSessionTabClose('live-host-pty', { + snapshotRepublished: !addressedDeadLeaf + }) + } + if (!closingWholeParent || this.tabs.has(tab.parentTabId)) { + // Why: only the renderer may retire its own tab or split leaf; a + // remote lifecycle echo must never cross that boundary into a kill. + return refusedMobileSessionTabClose('retirement-owner') + } + } + // Why: a runtime-owned headless tab is absent from renderer state, so the + // closeTerminalTab relay below would ack success without killing its PTY, + // and syncMobileSessionTabs would republish the "closed" tab. Only bypass + // the relay when no renderer owns the parent: an adopted tab needs the + // renderer's live pin guard and durable close transaction. + if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + allowMissingPersistedTab: Boolean(ptyCloseAuthority), + killPtys: + options.localPtyTeardownOwnedExternally !== true && + (options.reason === undefined || options.reason === 'user'), + ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) + }) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + return finishCommittedClose() + } + if (closingWholeParent && this.notifier?.closeTerminalTab) { + // Why: whole-tab close is a lifecycle transaction. The renderer reply + // arrives only after canonical retirement and a forced session flush. + const win = this.getAvailableAuthoritativeWindow() + if (win?.webContents.isDestroyed?.()) { + throw new Error('runtime_unavailable') + } + const releasePublicationThrottle = + options.clientNavigationId && win + ? this.rendererPublicationThrottle.acquire(win.webContents) + : () => {} + try { + await (options.localPtyTeardownOwnedExternally + ? this.notifier.closeTerminalTab(tab.parentTabId, { + localPtyTeardownOwnedExternally: true + }) + : this.notifier.closeTerminalTab(tab.parentTabId)) + } finally { + releasePublicationThrottle() + } + const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const remainingTab = remainingSnapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ) + if ( + remainingSnapshot && + remainingTab && + this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) + ) { + const remainingPtyCloseAuthority = options.expectedPtyCloseAuthority + ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) + : null + // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. + this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { + // Why: the renderer may already have durably removed the tab before acknowledging. + allowMissingPersistedTab: true, + ...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {}) + }) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + } + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) + return finishCommittedClose() + } + // Why: notifier implementations without the acknowledged relay may expose + // only raw pane close. Runtime-owned parents still need de-persist + kill. + if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) + }) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + return finishCommittedClose() + } + if (!this.notifier?.closeTerminal) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) + }) + return finishCommittedClose() + } + if (tab.id === tabId) { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + if (pty) { + if (this.ptyController?.kill(pty.ptyId) !== true) { + throw new Error('terminal_close_failed') + } + return finishCommittedClose() + } + this.notifier.closeTerminal(tab.parentTabId) + return delegatedMobileSessionTabClose() + } + // Why: paired web tab bars represent a split terminal with one local + // parent tab id. Closing that parent should close the desktop tab, not + // just whichever leaf happened to be first in the session snapshot. + this.notifier.closeTerminal(tab.parentTabId) + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) + return delegatedMobileSessionTabClose() + } else if (tab.type === 'browser') { + // Why: a browser tab can be hosted by a client, by the offscreen backend, + // or by the renderer; each surface owns a different retirement path. + const clientPage = tab.browserPageId + ? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId) + : undefined + if (clientPage) { + await this.browserTabClose({ + worktree: `id:${worktreeId}`, + page: clientPage.browserPageId + }) + } else if (this.isOffscreenMobileSessionBrowserTab(snapshot, tab)) { + await this.offscreenBrowserBackend!.closeTab(tab.browserPageId!).catch(() => {}) + this.retireRuntimeOwnedBrowserSessionTab(worktreeId, tab.browserPageId!) + } else { + if (!this.notifier?.closeSessionTab) { + throw new Error('runtime_unavailable') + } + await this.notifier.closeSessionTab(tab.id, worktreeId) + } + } else if (tab.type === 'agent-session') { + if (this.notifier?.closeSessionTab) { + try { + await this.notifier.closeSessionTab( + structuredAgentSessionTabId(tab.sessionId), + worktreeId + ) + } catch (error) { + // The renderer already having removed the tab is an idempotent close, not a veto. + if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { + throw error + } + } + } + await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) + } else { + if (!this.notifier?.closeSessionTab) { + throw new Error('runtime_unavailable') + } + await this.notifier.closeSessionTab(tab.id, worktreeId) + } + return finishCommittedClose() + } + + // Why: a refused echoed close means the echoing client already pruned its + // local mirror. Bump the version and emit the unchanged snapshot so clients + // that dedupe by snapshotVersion re-add and re-attach the still-live tab. + private republishMobileSessionTabsSnapshot(worktreeId: string): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (snapshot) { + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + } + this.notifyMobileSessionTabsChanged(worktreeId) + } + + private getMobileSessionTerminalHandle( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): string | null { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + if (!pty) { + return null + } + return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + } + + private getMobileSessionTerminalRetirementProof( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab, + authorizedPty?: RuntimePtyWorktreeRecord + ): RuntimeMobileSessionRetiredTerminalSurface | null { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) ?? authorizedPty ?? null + if (!pty || !this.getMobileTerminalLeafPtyIds(tab).includes(pty.ptyId)) { + return null + } + const terminal = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + if (!terminal) { + return null + } + const incarnationId = + pty.incarnationId ?? + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalPtyIncarnationsByPaneKey?.[ + this.getMobileTerminalPaneKey(tab) + ] + return { + parentTabId: tab.parentTabId, + leafId: tab.leafId, + ptyId: pty.ptyId, + terminal, + ...(incarnationId ? { incarnationId } : {}) + } + } + + private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { + // Why: this relay is advisory after main owns teardown; renderer failure must + // not prevent the authoritative session flush or turn the close into failure. + try { + this.notifier?.closeTerminal(parentTabId) + } catch (error) { + console.warn('[runtime] failed to notify renderer after headless terminal close', { + parentTabId, + error + }) + } + } + + private isOffscreenMobileSessionBrowserTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionBrowserTab + ): boolean { + if (!this.offscreenBrowserBackend || !tab.browserPageId) { + return false + } + if (this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch)) { + return true + } + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) + return ( + snapshot.publicationEpoch.includes(':headless-merge:') && + accepted !== undefined && + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + accepted.rendererTabIdentityKeys.has(id) + ) && + this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId) + ) + } + + // Public so runtime-side page release (lease fencing) can prune a tab whose page is gone. + retireRuntimeOwnedBrowserSessionTab(worktreeId: string, browserPageId: string): boolean { + // Why: before the snapshot guard — worktree removal drops the snapshot first, and the host + // rows for its client pages would otherwise be stranded on screen with nothing to retract them. + this.clientHostedBrowserRows.publish(worktreeId) + this.persistClientHostedBrowserPagesForWorktree(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return false + } + const retiredTab = snapshot.tabs.find( + (candidate): candidate is RuntimeMobileSessionBrowserTab => + candidate.type === 'browser' && candidate.browserPageId === browserPageId + ) + if (!retiredTab) { + return false + } + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== retiredTab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== retiredTab.id), + activeTabId: group.activeTabId === retiredTab.id ? null : group.activeTabId + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return true + } + + private async closeStructuredAgentSessionTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionAgentTab + ): Promise { + const host = getStructuredAgentSessionHost() + if (typeof host?.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(tab.sessionId, false) + } + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private markHeadlessBrowserSessionTabActive( + worktreeId: string | undefined, + browserPageId: string, + options: BrowserSessionTabSelectionOptions + ): void { + if (!worktreeId) { + return + } + const { targetGroupId, focusesHost } = options + // Why: client-placed pages publish through the page registry and need no offscreen backing. + if ( + !this.offscreenBrowserBackend && + !getRuntimeBrowserPageRegistry(this).getPage(browserPageId) + ) { + return + } + // Hydrate first so the freshly created browser tab is present in the snapshot. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionBrowserTab => + candidate.type === 'browser' && candidate.browserPageId === browserPageId + ) + if (!snapshot || !tab) { + return + } + const { + snapshot: nextSnapshot, + groups: nextGroups, + placedInTargetGroup + } = applyBrowserSessionTabSelection({ + snapshot, + tabId: tab.id, + ...(targetGroupId !== undefined ? { targetGroupId } : {}), + focusesHost, + publicationEpoch: `headless:${Date.now().toString(36)}` + }) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + // Why: browser group membership is otherwise live-only; persist it so a + // later rebuild keeps the browser in its group instead of coalescing left. + if (placedInTargetGroup && nextSnapshot.tabGroupLayout) { + this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout) + } + this.emitMobileSessionTabsSnapshot(nextSnapshot) + if (options.caller) { + // Why: the originating device still lands on the tab it just created; only the shared + // snapshot stayed put. Local creates keep the pre-navigation shape by having no caller. + this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + tab.id, + options.caller.navigation, + options.caller.clientNavigationId + ) + } + } + + private closeHeadlessMobileTerminalTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab, + options: { + allowMissingPersistedTab?: boolean + killPtys?: boolean + authorizedPty?: RuntimePtyWorktreeRecord + } = {} + ): void { + const closedParentTabId = tab.parentTabId + const retirementProofs = snapshot.tabs.flatMap((candidate) => { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + return [] + } + const proof = this.getMobileSessionTerminalRetirementProof( + worktreeId, + candidate, + options.authorizedPty + ) + return proof ? [proof] : [] + }) + const projectedPtyIds = this.commitHeadlessTerminalTabRetirement( + worktreeId, + closedParentTabId, + { allowMissing: options.allowMissingPersistedTab } + ) + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) + if (options.authorizedPty) { + options.authorizedPty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(options.authorizedPty.ptyId, false) + } + // Why: local provider ids can be reused after restart, so a dormant + // persisted id is not kill authority. SSH relay ids remain durable exact + // identities even before pane metadata reconnects. + const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) + for (const candidate of snapshot.tabs) { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + continue + } + const authorizedPty = + options.authorizedPty && + this.getMobileTerminalLeafPtyIds(candidate).includes(options.authorizedPty.ptyId) + ? options.authorizedPty + : null + const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) ?? authorizedPty + const ptyId = livePty?.ptyId ?? candidate.ptyId + const hasOtherOwner = snapshot.tabs.some( + (other) => + other.type === 'terminal' && + other.parentTabId !== closedParentTabId && + other.ptyId === ptyId + ) + if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { + // Why: a live serve leaf can exist before its debounced binding reaches + // persistence. Include it from the authoritative snapshot so split + // close cannot leave a provider process behind. + ptyIdsToKill.add(ptyId) + } + } + if (options.killPtys !== false) { + for (const ptyId of ptyIdsToKill) { + this.ptyController?.kill(ptyId) + } + } + const nextTabs = snapshot.tabs.filter((candidate) => { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + return true + } + return false + }) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: this.buildHeadlessMobileSessionTabGroups( + worktreeId, + nextTabs, + active, + snapshot.tabGroups + ), + ...(retirementProofs.length > 0 + ? { + retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs( + snapshot.retiredTerminalSurfaces, + retirementProofs + ) + } + : {}), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + async moveMobileSessionTab( + worktreeSelector: string, + move: RuntimeMobileSessionTabMove + ): Promise { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + throw new Error('tab_not_found') + } + if (!this.notifier?.moveSessionTab) { + return this.moveHeadlessMobileSessionTab(worktreeId, snapshot, move) + } + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const publicSnapshot = this.toMobileSessionTabsResult(snapshot) + const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) + if (!targetGroup) { + throw new Error('target_group_not_found') + } + + // Why: web clients address terminal surfaces as tab::leaf, while desktop + // tab grouping is owned by the outer terminal tab id. + if (move.kind === 'reorder') { + const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) + if (!tabOrder.includes(hostTabId)) { + throw new Error('invalid_tab_order') + } + this.notifier.moveSessionTab(worktreeId, { + ...move, + tabId: hostTabId, + tabOrder + }) + return { moved: true } + } + this.notifier.moveSessionTab(worktreeId, { + ...move, + tabId: hostTabId + }) + return { moved: true } + } + + // Why: pane geometry inside a tab (split ratios, expanded pane, pane titles) + // is host-authoritative for remote-server tabs but had no push path, so a + // client divider-drag / expand / pane-rename reverted on the next snapshot. + // Persist the structural fields onto the tab's layout, keeping host-owned + // pty bindings and active leaf. + async updateMobileSessionPaneLayout( + worktreeSelector: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): Promise<{ updated: true }> { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + // Why: when a renderer is authoritative (desktop host reached via shared + // control), it owns pane geometry and republishes it — a headless write here + // would be overwritten and could fight the renderer. Persist only headlessly. + if (this.getAvailableAuthoritativeWindow()) { + return { updated: true } + } + // Why: resolve to the host tab id (older/raw-id clients) so the persisted + // layout entry matches, matching setMobileSessionTabProps. + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const hostTabId = snapshot + ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) + : args.tabId + const resolvedArgs = { ...args, tabId: hostTabId } + const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs) + if (acceptedLayout) { + this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, { + tabId: hostTabId, + root: acceptedLayout.root, + expandedLeafId: acceptedLayout.expandedLeafId, + ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) + }) + } + return { updated: true } + } + + // Why: tab color/pin are host-authoritative for remote-server tabs but had no + // push path, so pinning or coloring a tab reverted on the next snapshot and + // was never persisted. Persist to the workspace session + live snapshot. + async setMobileSessionTabProps( + worktreeSelector: string, + args: { + tabId: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + } + ): Promise<{ updated: true }> { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + // Why: a renderer-authoritative host owns + republishes tab props, so a + // headless write would be overwritten. Persist only when headless. + if (this.getAvailableAuthoritativeWindow()) { + return { updated: true } + } + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const hostTabId = snapshot + ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) + : args.tabId + this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args) + this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args) + return { updated: true } + } + + private persistHeadlessSessionTabProps( + worktreeId: string, + tabId: string, + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } + ): void { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return + } + const tabs = session.tabsByWorktree[worktreeId] + const nextSession: WorkspaceSessionState = { ...session } + let changed = false + if (tabs?.some((tab) => tab.id === tabId)) { + changed = true + nextSession.tabsByWorktree = { + ...session.tabsByWorktree, + [worktreeId]: tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) + } + : tab + ) + } + } + + const unifiedTabs = session.unifiedTabs?.[worktreeId] + if (unifiedTabs?.some((tab) => tab.id === tabId || tab.entityId === tabId)) { + changed = true + nextSession.unifiedTabs = { + ...session.unifiedTabs, + [worktreeId]: unifiedTabs.map((tab) => + tab.id === tabId || tab.entityId === tabId + ? { + ...tab, + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + } + : tab + ) + } + } + + if (!changed) { + return + } + this.setWorkspaceSessionForWorktree(worktreeId, nextSession) + } + + private applyHeadlessSessionTabPropsToSnapshot( + worktreeId: string, + tabId: string, + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (this.getMobileSessionTopLevelTabId(tab) !== tabId) { + return tab + } + changed = true + return { + ...tab, + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) + } + }) + if (!changed) { + return + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private getMobileSessionTopLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string { + return tab.type === 'terminal' ? tab.parentTabId : tab.id + } + + // Merge the client's pane structure into the persisted tab layout. PTY + // bindings and active leaf stay host-owned; only ratios/expand/titles change. + // terminalLayoutsByTabId is keyed by tab id (worktree-independent). + private persistHeadlessTerminalPaneLayout( + worktreeId: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): TerminalLayoutSnapshot | undefined { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return undefined + } + const existing = session.terminalLayoutsByTabId?.[args.tabId] + if (!existing) { + return undefined + } + const candidate = { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...this.cloneTerminalLayoutSnapshot(existing), + root: args.root ?? existing.root, + expandedLeafId: args.expandedLeafId, + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) + } + } + } + this.setWorkspaceSessionForWorktree(worktreeId, candidate) + // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. + return ( + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? + candidate.terminalLayoutsByTabId[args.tabId] + ) + } + + private applyHeadlessTerminalPaneLayoutToSnapshot( + worktreeId: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal' || tab.parentTabId !== args.tabId || !tab.parentLayout) { + return tab + } + changed = true + return { + ...tab, + parentLayout: { + ...tab.parentLayout, + root: args.root ?? tab.parentLayout.root, + expandedLeafId: args.expandedLeafId, + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) + } + } + }) + if (!changed) { + return + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + + private moveHeadlessMobileSessionTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + move: RuntimeMobileSessionTabMove + ): RuntimeMobileSessionTabMoveResult { + if (move.kind === 'split') { + return this.splitHeadlessMobileSessionTabGroup(worktreeId, snapshot, move) + } + if (move.kind === 'move-to-group') { + return this.moveHeadlessMobileSessionTabToGroup(worktreeId, snapshot, move) + } + if (move.kind !== 'reorder') { + throw new Error('renderer_unavailable') + } + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const publicSnapshot = this.toMobileSessionTabsResult(snapshot) + const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) + if (!targetGroup) { + throw new Error('target_group_not_found') + } + const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) + const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) + const nextTabs = [...snapshot.tabs].sort((a, b) => { + const aParent = a.type === 'terminal' ? a.parentTabId : a.id + const bParent = b.type === 'terminal' ? b.parentTabId : b.id + const aIndex = orderIndexByParentTabId.get(aParent) ?? Number.MAX_SAFE_INTEGER + const bIndex = orderIndexByParentTabId.get(bParent) ?? Number.MAX_SAFE_INTEGER + return aIndex - bIndex + }) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const reorderedTargetActiveTabId = + active?.type === 'terminal' ? active.parentTabId : active ? active.id : (tabOrder[0] ?? null) + // Why: reorder only changes ONE group's order. Preserve every other group so + // a multi-group split isn't deleted by re-sorting tabs in one of its groups. + const existingGroups = snapshot.tabGroups ?? [] + const nextGroups = existingGroups.some((group) => group.id === targetGroup.id) + ? existingGroups.map((group) => + group.id === targetGroup.id + ? { ...group, tabOrder, activeTabId: reorderedTargetActiveTabId } + : group + ) + : [{ ...targetGroup, tabOrder, activeTabId: reorderedTargetActiveTabId }] + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: nextGroups, + tabs: nextTabs + } + this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) + if (nextGroups.length > 1 && snapshot.tabGroupLayout) { + this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return { moved: true } + } + + // Why: a drag-to-split-group used to be a client-only change the headless host + // never modeled, so the next snapshot coalesced every tab back into one group. + // Model + persist the multi-group layout so the split survives rebuilds. + private splitHeadlessMobileSessionTabGroup( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + move: Extract + ): RuntimeMobileSessionTabMoveResult { + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const split = buildHeadlessTabGroupSplit({ + groups: snapshot.tabGroups ?? [], + layout: snapshot.tabGroupLayout, + tabId: hostTabId, + targetGroupId: move.targetGroupId, + splitDirection: move.splitDirection, + newGroupId: randomUUID() + }) + if (!split) { + // Renderer treats an unsplittable drop (e.g. last tab onto its own group) + // as a no-op; mirror that instead of churning the snapshot. + return { moved: true } + } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeGroupId: split.newGroupId, + tabGroups: split.groups, + tabGroupLayout: split.layout + } + this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return { moved: true } + } + + // Move a tab into an existing group on a headless serve (non-split drop). + private moveHeadlessMobileSessionTabToGroup( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + move: Extract + ): RuntimeMobileSessionTabMoveResult { + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) + if (!hostTabId) { + throw new Error('tab_not_found') + } + const moved = buildHeadlessTabGroupMove({ + groups: snapshot.tabGroups ?? [], + layout: snapshot.tabGroupLayout, + tabId: hostTabId, + targetGroupId: move.targetGroupId, + index: move.index + }) + if (!moved) { + // Same-group / missing-target drop is a renderer no-op; mirror that. + return { moved: true } + } + const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: snapshot.snapshotVersion + 1, + activeGroupId: move.targetGroupId, + tabGroups: moved.groups, + tabGroupLayout: layout + } + this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + return { moved: true } + } + + // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. + private persistHeadlessTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + layout: TabGroupLayoutNode + ): void { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return + } + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + tabGroups: { + ...session.tabGroups, + [worktreeId]: groups.map((group) => ({ + id: group.id, + worktreeId, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + })) + }, + tabGroupLayouts: { + ...session.tabGroupLayouts, + [worktreeId]: layout + } + }) + } + + // Persist a manual terminal rename so a headless rebuild keeps the title + // instead of reverting to the generated/default one. + private persistHeadlessTerminalTitle( + worktreeId: string, + tabId: string, + title: string | null + ): void { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + return + } + const tabs = session.tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return + } + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, customTitle: title } : tab)) + } + }) + } + + private normalizeMobileSessionTabOrder( + snapshot: RuntimeMobileSessionTabsSnapshot | undefined, + targetGroup: RuntimeMobileSessionTabGroup, + tabOrder: readonly string[] + ): string[] { + const normalized: string[] = [] + const seen = new Set() + for (const tabId of tabOrder) { + const hostTabId = this.resolveMobileSessionHostTabId(snapshot, tabId) + if (!hostTabId) { + throw new Error('invalid_tab_order') + } + if (seen.has(hostTabId)) { + throw new Error('duplicate_tab_order') + } + seen.add(hostTabId) + normalized.push(hostTabId) + } + + const returnedIds = this.collectPublicMobileSessionTabIds(snapshot) + const expected = targetGroup.tabOrder + .map((tabId) => this.resolveMobileSessionHostTabId(snapshot, tabId) ?? tabId) + // Why: clients reorder the sanitized session.tabs.list model; raw groups + // can still contain stale browser ids hidden from paired web clients. + .filter((tabId) => returnedIds.has(tabId)) + const structuredIds = expected.filter((tabId) => + snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId) + ) + if (structuredIds.some((tabId) => !seen.has(tabId))) { + if (structuredIds.some((tabId) => seen.has(tabId))) { + throw new Error('invalid_tab_order') + } + const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId)) + if ( + normalized.length !== visibleExpected.length || + visibleExpected.some((tabId) => !seen.has(tabId)) + ) { + throw new Error('invalid_tab_order') + } + for (const tabId of structuredIds) { + normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId) + } + return normalized + } + // Why: reorder is a pure permutation of one existing group. Missing or + // extra ids would let a paired web client silently move/lose host tabs. + if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) { + throw new Error('invalid_tab_order') + } + return normalized + } + + private collectPublicMobileSessionTabIds( + snapshot: RuntimeMobileSessionTabsSnapshot | undefined + ): Set { + const ids = new Set() + if (!snapshot) { + return ids + } + const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) + for (const tab of snapshot.tabs) { + if (tab.type === 'browser') { + const liveTab = tab.browserPageId + ? liveBrowserTabsByPageId.get(tab.browserPageId) + : undefined + if (!liveTab) { + continue + } + ids.add(tab.id) + ids.add(tab.browserWorkspaceId) + continue + } + ids.add(tab.id) + if (tab.type === 'terminal') { + ids.add(tab.parentTabId) + } + } + return ids + } + + private resolveMobileSessionHostTabId( + snapshot: RuntimeMobileSessionTabsSnapshot | undefined, + tabId: string + ): string | null { + const tab = + snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId + ) ?? + snapshot?.tabs.find( + (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId + ) + if (!tab) { + return null + } + return tab.type === 'terminal' ? tab.parentTabId : tab.id + } + + async readMobileMarkdownTab( + worktreeSelector: string, + tabId: string + ): Promise { + const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) + if (!this.notifier?.readMobileMarkdownTab) { + throw new Error('renderer_unavailable') + } + return await this.notifier.readMobileMarkdownTab(worktreeId, tabId) + } + + async saveMobileMarkdownTab( + worktreeSelector: string, + tabId: string, + baseVersion: string, + content: string + ): Promise { + const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) + if (!this.notifier?.saveMobileMarkdownTab) { + throw new Error('renderer_unavailable') + } + return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) + } + + private readonly fileCommands = new RuntimeFileCommands({ + getRuntimeId: () => this.runtimeId, + requireStore: () => this.requireStore(), + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), + resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) => + this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId), + resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), + resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), + resolveTerminalFileUriHostname: (terminalHandle) => + this.resolveTerminalFileUriHostname(terminalHandle), + hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => + this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), + hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) => + nativeChatTranscriptIncludesPath({ + tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs, + context, + pathText, + absolutePath + }), + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), + openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { + if (!this.notifier?.openFile) { + throw new Error('renderer_unavailable') + } + this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId) + }, + openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => { + if (!this.notifier?.openDiff) { + throw new Error('renderer_unavailable') + } + this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) + } + }) + + listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( + this.fileCommands + ) + searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = + this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) + searchQuickOpenFilePaths: RuntimeFileCommands['searchQuickOpenFilePaths'] = + this.fileCommands.searchQuickOpenFilePaths.bind(this.fileCommands) + openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( + this.fileCommands + ) + openMobileDiff: RuntimeFileCommands['openMobileDiff'] = this.fileCommands.openMobileDiff.bind( + this.fileCommands + ) + readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind( + this.fileCommands + ) + resolveTerminalPath: RuntimeFileCommands['resolveTerminalPath'] = + this.fileCommands.resolveTerminalPath.bind(this.fileCommands) + readTerminalArtifactFile: RuntimeFileCommands['readTerminalArtifactFile'] = + this.fileCommands.readTerminalArtifactFile.bind(this.fileCommands) + readTerminalArtifactPreview: RuntimeFileCommands['readTerminalArtifactPreview'] = + this.fileCommands.readTerminalArtifactPreview.bind(this.fileCommands) + writeTerminalArtifactFile: RuntimeFileCommands['writeTerminalArtifactFile'] = + this.fileCommands.writeTerminalArtifactFile.bind(this.fileCommands) + revokeTerminalFileGrantsForClient: RuntimeFileCommands['revokeTerminalFileGrantsForClient'] = + this.fileCommands.revokeTerminalFileGrantsForClient.bind(this.fileCommands) + readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = + this.fileCommands.readFileExplorerDir.bind(this.fileCommands) + watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = + this.fileCommands.watchFileExplorer.bind(this.fileCommands) + closeFileWatchersForRemoval = async ( + worktreePath: string, + connectionId?: string, + deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline() + ): Promise => { + // Why drain the remote/explorer closes: they await SSH round trips and lease suspends that a dead + // link never answers. The local close bounds its own awaits against the same deadline internally. + const results = await Promise.allSettled([ + connectionId + ? drainBeforeWatcherRemoval( + getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath), + deadline, + `remote watcher close for ${worktreePath}` + ) + : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline), + drainBeforeWatcherRemoval( + this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), + deadline, + `file explorer watcher close for ${worktreePath}` + ) + ]) + const failure = results.find((result): result is PromiseRejectedResult => { + return result.status === 'rejected' + }) + if (failure) { + // Why: restoration must start only after every bounded teardown settles; + // otherwise a late close can stale a just-restored logical subscription. + throw failure.reason + } + } + restoreFileWatchersAfterFailedRemoval = async ( + worktreePath: string, + connectionId?: string + ): Promise => { + await Promise.all([ + connectionId + ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath) + : getWorktreeWatcherRemoval().restoreLocal(worktreePath), + this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) + ]) + } + forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { + if (connectionId) { + getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath) + } else { + getWorktreeWatcherRemoval().forgetLocal(worktreePath) + } + this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) + } + acquireFileWatcherRemoval = async ( + worktreePath: string, + connectionId?: string + ): Promise<{ finish(removed: boolean): Promise }> => { + const gate = acquireWatcherRemovalGate(worktreePath, connectionId) + // Why: one budget for the whole preparation — independent per-await timeouts would compose into minutes. + const deadline = createWatcherRemovalDeadline() + try { + // Why: the first pass aborts desktop setup immediately; the second catches + // any pre-gate runtime install that published after the first snapshot. + await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) + // Why: a wedged install never releases its fence slot, so gate.ready can hang forever; the delete + // must proceed instead, or the gate stays held and every later install under this root is rejected. + const fenceDrain = await drainBeforeWatcherRemoval( + gate.ready, + deadline, + `watcher install fence for ${worktreePath}` + ) + if (fenceDrain === 'timeout') { + // Why: a wedged install holds its fence slot for the process lifetime, so leaving it counted + // makes every later removal of this root burn the whole drain budget again. + gate.abandonPendingInstalls() + } + await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) + let finished = false + return { + finish: async (removed) => { + if (finished) { + return + } + finished = true + if (removed) { + this.forgetFileWatchersAfterRemoval(worktreePath, connectionId) + } + gate.release() + if (!removed) { + await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( + (restoreError: unknown) => { + console.error('[worktrees] failed to restore watchers after removal failed', { + worktreePath, + restoreError + }) + } + ) + } + } + } + } catch (error) { + gate.release() + await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( + (restoreError: unknown) => { + console.error('[worktrees] failed to restore watchers after removal setup failed', { + worktreePath, + restoreError + }) + } + ) + throw error + } + } + readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = + this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) + readDocPreviewFile: RuntimeFileCommands['readDocPreviewFile'] = + this.fileCommands.readDocPreviewFile.bind(this.fileCommands) + readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] = + this.fileCommands.readFileExplorerChunk.bind(this.fileCommands) + writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = + this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) + writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = + this.fileCommands.writeFileExplorerFileBase64.bind(this.fileCommands) + writeFileExplorerFileBase64Chunk: RuntimeFileCommands['writeFileExplorerFileBase64Chunk'] = + this.fileCommands.writeFileExplorerFileBase64Chunk.bind(this.fileCommands) + createFileExplorerFile: RuntimeFileCommands['createFileExplorerFile'] = + this.fileCommands.createFileExplorerFile.bind(this.fileCommands) + createFileExplorerDir: RuntimeFileCommands['createFileExplorerDir'] = + this.fileCommands.createFileExplorerDir.bind(this.fileCommands) + createFileExplorerDirNoClobber: RuntimeFileCommands['createFileExplorerDirNoClobber'] = + this.fileCommands.createFileExplorerDirNoClobber.bind(this.fileCommands) + commitFileExplorerUpload: RuntimeFileCommands['commitFileExplorerUpload'] = + this.fileCommands.commitFileExplorerUpload.bind(this.fileCommands) + renameFileExplorerPath: RuntimeFileCommands['renameFileExplorerPath'] = + this.fileCommands.renameFileExplorerPath.bind(this.fileCommands) + copyFileExplorerPath: RuntimeFileCommands['copyFileExplorerPath'] = + this.fileCommands.copyFileExplorerPath.bind(this.fileCommands) + deleteFileExplorerPath: RuntimeFileCommands['deleteFileExplorerPath'] = + this.fileCommands.deleteFileExplorerPath.bind(this.fileCommands) + searchRuntimeFiles: RuntimeFileCommands['searchRuntimeFiles'] = + this.fileCommands.searchRuntimeFiles.bind(this.fileCommands) + listRuntimeFiles: RuntimeFileCommands['listRuntimeFiles'] = + this.fileCommands.listRuntimeFiles.bind(this.fileCommands) + listRuntimeMarkdownDocuments: RuntimeFileCommands['listRuntimeMarkdownDocuments'] = + this.fileCommands.listRuntimeMarkdownDocuments.bind(this.fileCommands) + statRuntimeFile: RuntimeFileCommands['statRuntimeFile'] = this.fileCommands.statRuntimeFile.bind( + this.fileCommands + ) + + private readonly gitCommands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), + getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, + getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined, + // Why: resolved worktrees are cached for a second, so link/unlink would lag + // generation; meta is keyed by the same id the resolver returns. + getWorktreeLinkedIssue: (worktreeId) => { + const store = this.store + // Why: an unreadable store is "unknown", not "unlinked" — undefined keeps + // the resolver's cached linkedIssue instead of suppressing {linkedIssue}. + if (!store?.getWorktreeMeta) { + return undefined + } + return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null + }, + getWorktreeLinkedIssueMeta: (worktreeId) => { + const store = this.store + if (!store?.getWorktreeMeta) { + return undefined + } + const meta = store.getWorktreeMeta(worktreeId) + return meta + ? { + linkedIssue: meta.linkedIssue, + linkedGitLabIssue: meta.linkedGitLabIssue, + linkedWorkItem: meta.linkedWorkItem + } + : null + } + }) + + getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = + this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + getRuntimeGitSubmoduleStatus: RuntimeGitCommands['getRuntimeGitSubmoduleStatus'] = + this.gitCommands.getRuntimeGitSubmoduleStatus.bind(this.gitCommands) + checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] = + this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands) + getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = + this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) + getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = + this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) + abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] = + this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) + abortRuntimeGitRebase: RuntimeGitCommands['abortRuntimeGitRebase'] = + this.gitCommands.abortRuntimeGitRebase.bind(this.gitCommands) + checkoutRuntimeGitBranch: RuntimeGitCommands['checkoutRuntimeGitBranch'] = + this.gitCommands.checkoutRuntimeGitBranch.bind(this.gitCommands) + listRuntimeGitLocalBranches: RuntimeGitCommands['listRuntimeGitLocalBranches'] = + this.gitCommands.listRuntimeGitLocalBranches.bind(this.gitCommands) + getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = + this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) + getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = + this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) + getRuntimeGitCommitCompare: RuntimeGitCommands['getRuntimeGitCommitCompare'] = + this.gitCommands.getRuntimeGitCommitCompare.bind(this.gitCommands) + getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = + this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) + fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( + this.gitCommands + ) + syncRuntimeGitForkDefaultBranch: RuntimeGitCommands['syncRuntimeGitForkDefaultBranch'] = + this.gitCommands.syncRuntimeGitForkDefaultBranch.bind(this.gitCommands) + pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( + this.gitCommands + ) + fastForwardRuntimeGit: RuntimeGitCommands['fastForwardRuntimeGit'] = + this.gitCommands.fastForwardRuntimeGit.bind(this.gitCommands) + rebaseRuntimeGitFromBase: RuntimeGitCommands['rebaseRuntimeGitFromBase'] = + this.gitCommands.rebaseRuntimeGitFromBase.bind(this.gitCommands) + pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( + this.gitCommands + ) + getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = + this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) + getRuntimeGitCommitDiff: RuntimeGitCommands['getRuntimeGitCommitDiff'] = + this.gitCommands.getRuntimeGitCommitDiff.bind(this.gitCommands) + commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( + this.gitCommands + ) + generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = + this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) + discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] = + this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands) + cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = + this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) + generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = + this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) + cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = + this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) + stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = + this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) + unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = + this.gitCommands.unstageRuntimeGitPath.bind(this.gitCommands) + bulkStageRuntimeGitPaths: RuntimeGitCommands['bulkStageRuntimeGitPaths'] = + this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) + bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = + this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) + bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] = + this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands) + discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = + this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) + getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = + this.gitCommands.getRuntimeGitRemoteFileUrl.bind(this.gitCommands) + getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = + this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) + + /** + * Installs the structured agent-session host on first use. Lazy for the same + * reason the orchestration DB is: the profile's user-data path is not final + * until the app is ready, and a runtime nobody drives a chat session on + * should never open the record store. + */ + async ensureStructuredAgentSessionHost(): Promise { + await installStructuredAgentSessionHost({ + stateDirectory: getProfileUserDataPath(), + hostId: LOCAL_EXECUTION_HOST_ID, + claimKeyId: this.agentSessionClaimSigner.keyId, + // Resolves folder workspaces as well as git worktrees, so a chat session + // in a plain folder lands in the folder rather than failing to resolve. + resolveWorkspacePath: async (workspaceId) => + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchEnvOverlay: () => + resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + handoffTransport: this.createStructuredAgentSessionHandoffTransport() + }) + } + + private resolveConfiguredCodexStructuredArgs(): string[] { + const settings = this.requireStore().getSettings() + const shell = resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + return resolveCodexStructuredAppServerArgs( + resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), + shell ?? 'posix' + ) + } + + private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: hostname(), + launchTui: async ({ record, fence, spawnToken, onSpawned }) => { + const head = record.providerHandleChain.at(-1) + if (!head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude')) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const launchStartedAt = Date.now() + const launched = await this.ensureAgentSession( + { + kind: 'explicit', + worktree: `id:${record.location.workspaceId}`, + agent: provider, + providerSession: { key: 'session_id', id: providerSessionId }, + ...(record.options ? { launchPreferences: record.options } : {}), + presentation: 'background' + }, + {}, + { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } + ) + const terminal = launched.terminal + let spawnedOwner: StructuredTuiOwner | null = null + let ptyId: string | undefined + try { + if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { + throw new Error('The resumed terminal did not publish a process identity.') + } + ptyId = terminal.ptyId + spawnedOwner = this.refreshStructuredTuiOwnerBinding({ + terminal: { + handle: terminal.handle, + tabId: terminal.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: + provider === 'codex' + ? await readCodexResumeProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + threadId: head.handle.threadId + }) + : await readStructuredTuiProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + agent: provider + }), + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + }) + await onSpawned?.(spawnedOwner) + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = + provider === 'codex' + ? await this.waitForAdoptedStructuredTuiProof({ + owner: spawnedOwner, + threadId: head.handle.threadId, + codexHome: record.accountHome.path + }) + : await this.waitForStructuredClaudeTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects'), + spawnToken, + minimumProviderSessionReceivedAt: launchStartedAt + }) + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + ...spawnedOwner, + link: + provider === 'claude' + ? claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + : spawnedOwner.link, + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: spawnedOwner.process, + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), + historySource: 'provider-resume' + }) + } catch (error) { + let closeError: unknown = null + try { + await this.closeTerminal(terminal.handle) + } catch (cleanupFailure) { + closeError = cleanupFailure + } + try { + // closeTerminal may retire the renderer handle before the PTY exit is + // observed. Prove the provider child (or, before identity publication, + // the PTY) through the same exit path used by handoff recovery. + if (spawnedOwner) { + await this.waitForStructuredTuiOwnerExit(spawnedOwner) + } else if (ptyId) { + await this.waitForStructuredTuiPtyExit(ptyId) + } else { + throw new Error('The failed terminal did not publish a PTY identity.') + } + } catch (exitFailure) { + throw new StructuredTuiLaunchCleanupError( + error, + closeError === null + ? exitFailure + : new AggregateError( + [closeError, exitFailure], + 'Structured TUI cleanup could not prove process exit.' + ) + ) + } + throw error + } + }, + waitForTuiExit: async (owner) => { + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + }, + waitForTuiIdleOrExit: async (owner, signal) => { + return this.waitForStructuredTuiIdleOrExit(owner, signal) + }, + reproveTuiOwner: async ({ record, owner }) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) + const persisted = record.lease.ownerProcess + if ( + !persisted || + persisted.hostId !== current.process.hostId || + persisted.pid !== current.process.pid || + persisted.processStartTimeMs !== current.process.processStartTimeMs || + persisted.spawnToken !== current.process.spawnToken + ) { + throw new Error('The owning terminal does not match the persisted launch identity.') + } + const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error( + `The owning ${current.link.handle.provider} child process could not be re-proved.` + ) + } + const head = record.providerHandleChain.at(-1) + const sameProviderIdentity = + head && + (current.link.handle.provider === 'claude' + ? agentSessionProviderHandleRoot(current.link.handle) === + agentSessionProviderHandleRoot(head.handle) + : (record.lease.provenHandleLinkId === null || + current.link.linkId === record.lease.provenHandleLinkId) && + agentSessionProviderHandlesEqual(current.link.handle, head.handle)) + if (!sameProviderIdentity) { + throw new Error('agent_session_identity_required') + } + if (current.link.handle.provider === 'claude' && head.handle.provider === 'claude') { + const proof = await this.waitForStructuredClaudeTuiProof({ + handle: current.terminal.handle, + paneKey: current.terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + ...current, + link: claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + } + if (current.transcriptPath || current.link.handle.provider !== 'codex') { + return current + } + if (head.handle.provider !== 'codex') { + return current + } + const threadId = head.handle.threadId + const transcriptPath = await resolvePinnedCodexRolloutProof( + record.accountHome.path, + threadId + ) + return transcriptPath ? { ...current, transcriptPath } : current + }, + recoverTuiOwner: async (record) => { + const identity = record.lease.ownerProcess + const head = record.providerHandleChain.at(-1) + if ( + !identity || + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + let candidate = [...this.ptysById.values()].find( + (pty) => + pty.connected && + pty.launchToken === identity.spawnToken && + pty.launchAgent === provider && + pty.tabId && + pty.paneKey + ) + let handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null + let durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } | undefined + if (!candidate) { + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${record.location.workspaceId}` + ) + const baseNamespace = this.getAgentSessionExecutionNamespace(workspace, provider) + if (!baseNamespace || !worktreeIdsEqual(workspace.id, record.location.workspaceId)) { + throw new Error('agent_session_identity_required') + } + const claim = this.agentSessionClaimSigner.createClaim({ + namespace: { ...baseNamespace, providerRoot: record.accountHome.path }, + identity: canonicalizeAgentSessionIdentity(provider, { + key: 'session_id', + id: providerSessionId + }), + canonicalWorktreeId: workspace.id + }) + const candidateEvaluations = [...this.ptysById.values()].flatMap((pty) => + pty.agentSessionOwners.map((owner) => { + const session = this.getWorkspaceSessionForWorktree(owner.surface.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, owner.surface.worktreeId) + : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (candidate) => candidate.id === owner.surface.tabId + ) + : null + const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId) + const persisted = { + sessionResolved: Boolean(session && sessionWorktreeId), + tabPresent: Boolean(persistedTab), + ptyId: + session?.terminalLayoutsByTabId[owner.surface.tabId]?.ptyIdsByLeafId?.[ + owner.surface.leafId + ] ?? null, + incarnationId: session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null + } + const evaluation = evaluateStructuredTuiRecoveryClaim( + { + expectedWorkspaceId: workspace.id, + claimMatches: scopedAgentSessionClaimsEqual(owner.claim, claim), + pty: { + connected: pty.connected, + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId + }, + owner: { + phase: owner.phase, + ptyId: owner.ptyId, + surface: owner.surface + }, + persisted + }, + worktreeIdsEqual + ) + return { pty, owner, persisted, evaluation } + }) + ) + const recoveredCandidates = candidateEvaluations + .filter(({ evaluation }) => evaluation.matches) + .map(({ pty, owner }) => ({ pty, owner })) + const recovered = recoveredCandidates.length === 1 ? recoveredCandidates[0] : null + if (!recovered) { + console.warn('[structured-tui-recovery] claim mismatch', { + sessionId: record.sessionId, + expectedWorkspaceId: workspace.id, + persistedOwnerProcess: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs, + spawnTokenPresent: identity.spawnToken.length > 0 + }, + candidates: candidateEvaluations.map(({ pty, owner, persisted, evaluation }) => ({ + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId, + ownerSurface: owner.surface, + persisted, + mismatchedFields: evaluation.mismatchedFields + })) + }) + } + if ( + !recovered || + !(await this.proveRecoveredStructuredTuiPtyProcess(recovered.pty, identity, provider)) + ) { + throw new Error('The owning agent terminal could not be recovered.') + } + candidate = recovered.pty + candidate.tabId = recovered.owner.surface.tabId + candidate.paneKey = makePaneKey( + recovered.owner.surface.tabId, + recovered.owner.surface.leafId + ) + // Runtime handles rotate on packaged relaunch; claim, incarnation, and process proof are durable. + handle = this.issuePtyHandle(candidate) + const recoveredIncarnationId = candidate.incarnationId + if (handle && recoveredIncarnationId) { + durableOwner = { + binding: cloneAgentSessionOwnerBinding(recovered.owner), + incarnationId: recoveredIncarnationId + } + } + } + if (!candidate?.tabId || !candidate.paneKey || !handle) { + throw new Error('The owning agent terminal could not be recovered.') + } + agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) + const proof = + provider === 'codex' + ? durableOwner + ? await this.resolveRecoveredStructuredTuiTranscript({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + codexHome: record.accountHome.path, + durableOwner + }) + : await this.waitForStructuredTuiProof({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + spawnToken: identity.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + : await this.waitForStructuredClaudeTuiProof({ + handle, + paneKey: candidate.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + terminal: { + handle, + tabId: candidate.tabId, + paneKey: candidate.paneKey, + ptyId: candidate.ptyId + }, + process: identity, + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + }, + probeRecoveredOwner: async (record) => { + const identity = record.lease.ownerProcess + if (!identity) { + return 'dead' + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'identity-matched' && proof.matchedOn.length > 0) { + return 'live' + } + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return 'dead' + } + return 'unknown' + }, + stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), + tuiStatus: (owner) => this.structuredTuiStatus(owner), + closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), + revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { + if (adoptedTerminal || agent !== 'codex') { + return + } + await this.publishStructuredAgentSessionTab({ + workspaceId, + sessionId, + agent, + activate: false + }) + this.notifier?.focusEditorTab?.(structuredAgentSessionTabId(sessionId), workspaceId) + }, + stopFailedTuiLaunch: async (owner) => void (await this.closeStructuredTuiOwner(owner)) + } + } + + private async proveRecoveredStructuredTuiPtyProcess( + pty: RuntimePtyWorktreeRecord, + identity: NonNullable, + provider: 'codex' | 'claude' = 'codex' + ): Promise { + const listings = await this.ptyController?.listProcesses?.(pty.connectionId) + const listed = listings?.find( + (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === pty.incarnationId + ) + if (!listed?.rootProcessId || identity.processStartTimeMs === null) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed?.rootProcessId ?? null, + mismatchedFields: [ + ...(!listed?.rootProcessId ? ['root-process-id'] : []), + ...(identity.processStartTimeMs === null ? ['persisted-process-start-time'] : []) + ] + }) + return false + } + try { + const observed = await readStructuredTuiProcessIdentity({ + hostId: identity.hostId, + rootPid: listed.rootProcessId, + spawnToken: identity.spawnToken, + agent: provider + }) + const matched = { + hostId: observed.hostId === identity.hostId, + pid: observed.pid === identity.pid, + processStartTime: + observed.processStartTimeMs !== null && + Math.abs(observed.processStartTimeMs - identity.processStartTimeMs) <= + PROCESS_START_TIME_TOLERANCE_MS + } + if (!Object.values(matched).every(Boolean)) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + persisted: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs + }, + observed: { + hostId: observed.hostId, + pid: observed.pid, + processStartTimeMs: observed.processStartTimeMs + }, + mismatchedFields: Object.entries(matched) + .filter(([, matches]) => !matches) + .map(([field]) => field) + }) + } + return Object.values(matched).every(Boolean) + } catch (error) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + mismatchedFields: [`${provider}-child-proof`], + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + private async closeStructuredTuiOwner( + owner: StructuredTuiOwner + ): Promise<{ transcriptPath?: string }> { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + const current = this.refreshStructuredTuiOwnerBinding(owner) + try { + await this.closeTerminal(current.terminal.handle) + } catch (error) { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + throw error + } + } + } + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + } + + // The new exact `codex resume ` child proves the resumed owner without + // a first turn; the pinned rollout then binds its durable transcript. + private async waitForAdoptedStructuredTuiProof(input: { + owner: StructuredTuiOwner + threadId: string + codexHome: string + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertPaneIdentity = (): void => { + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { + throw new Error('The adopted terminal lost its pane identity.') + } + } + assertPaneIdentity() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + assertPaneIdentity() + const processProof = await probeAgentSessionProcessIdentity({ identity: input.owner.process }) + if (processProof.outcome !== 'identity-matched' || processProof.matchedOn.length === 0) { + throw new Error('The resumed Codex process could not be re-proved.') + } + return { transcriptPath } + } + + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected) { + throw new Error('The owning agent terminal lost its launch identity.') + } + const handle = this.issueStructuredTuiPtyHandle(pty) + if (handle === owner.terminal.handle) { + return owner + } + return { ...owner, terminal: { ...owner.terminal, handle } } + } + + private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private async waitForStructuredTuiPtyExit(ptyId: string): Promise { + const deadline = Date.now() + 5_000 + while (this.ptysById.get(ptyId)?.connected === true) { + if (Date.now() >= deadline) { + throw new Error('terminal_handle_stale') + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) + }) + } + + private async waitForStructuredTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> { + const deadline = Date.now() + 250 + while (!signal.aborted && Date.now() < deadline) { + if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { + await this.waitForStructuredTuiOwnerExit(owner) + return 'exited' + } + if (this.structuredTuiStatus(owner) === 'idle') { + return 'idle' + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } + + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { + const identity = record.lease.ownerProcess + if (!identity) { + return + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The recovered owner process could not be stopped safely.') + } + try { + process.kill(identity.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + // SIGTERM is only a request. Escalate once, then require an independent + // absence probe before allowing the lease transition to proceed. + try { + process.kill(identity.pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const forcedDeadline = Date.now() + 5_000 + while (Date.now() < forcedDeadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('The recovered owner process did not exit after forced termination.') + } + + private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { + const pty = this.ptysById.get(owner.terminal.ptyId) + const paneKey = pty?.paneKey ?? owner.terminal.paneKey + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } + if (pty?.connected) { + const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + const blocked = detectTerminalWaitBlockedReason(text) !== null + if (!blocked && isKnownReadyPromptPreview(text)) { + return 'idle' + } + return hasStructuredTuiIdleEvidence({ + blocked, + status: pty.lastAgentStatus, + statusObservedLive: pty.lastAgentStatusObservedLive + }) + ? 'idle' + : 'busy' + } + return 'busy' + } + + private async waitForStructuredTuiProof(input: { + handle: string + paneKey: string + threadId: string + spawnToken: string + codexHome: string + sessionId: string + }): Promise<{ transcriptPath?: string; leafUuid?: never }> { + const readBoundPty = (): RuntimePtyWorktreeRecord => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.launchAgent !== 'codex' || + pty.launchToken !== input.spawnToken + ) { + throw new Error('The resumed terminal lost its launch identity.') + } + return pty + } + const initialPty = readBoundPty() + const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags, + readOutput: () => { + const pty = readBoundPty() + return { + text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + lastOutputAt: pty.lastOutputAt + } + }, + write: (data) => { + const pty = readBoundPty() + return ( + this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + ) + } + }) + } + + private async waitForStructuredClaudeTuiProof(input: { + handle: string + paneKey: string + sessionId: string + previousLeafUuid: string | null + projectsDir: string + /** Set when this call launched a new Claude process; a cached transcript marker is not enough. */ + spawnToken?: string + minimumProviderSessionReceivedAt?: number + }): Promise<{ transcriptPath: string; leafUuid: string }> { + const deadline = Date.now() + 15_000 + let incompleteTail: ClaudeTranscriptTailIncompleteError | null = null + while (Date.now() < deadline) { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if (!pty?.connected || pty.paneKey !== input.paneKey || pty.launchAgent !== 'claude') { + throw new Error('The resumed Claude terminal lost its launch identity.') + } + if (input.spawnToken) { + if (!this.hasProviderSessionObservationSource()) { + throw new Error('The Claude terminal could not prove its fresh provider session.') + } + const observedProviderRow = this.findAdoptedProviderSession( + input.paneKey, + 'claude', + input.sessionId + ) + if ( + !observedProviderRow || + observedProviderRow.launchToken !== input.spawnToken || + (input.minimumProviderSessionReceivedAt !== undefined && + observedProviderRow.receivedAt < input.minimumProviderSessionReceivedAt) + ) { + await new Promise((resolve) => setTimeout(resolve, 100)) + continue + } + } + const transcriptPath = await resolveSessionFilePath('claude', input.sessionId, { + claudeProjectsDir: input.projectsDir + }) + if (transcriptPath) { + if (!isPathWithinDirectory(input.projectsDir, transcriptPath)) { + throw new Error('The Claude terminal reported a transcript outside its account root.') + } + try { + const leafUuid = await readClaudeTranscriptLeafUuid( + transcriptPath, + input.sessionId, + input.previousLeafUuid + ) + return { transcriptPath, leafUuid } + } catch (error) { + if (!(error instanceof ClaudeTranscriptTailIncompleteError)) { + throw error + } + incompleteTail = error + } + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (incompleteTail) { + throw incompleteTail + } + throw new Error('The agent terminal did not prove the expected Claude session.') + } + + private async resolveRecoveredStructuredTuiTranscript(input: { + handle: string + paneKey: string + threadId: string + codexHome: string + durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertDurableOwner = (): void => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.incarnationId !== input.durableOwner.incarnationId || + !pty.agentSessionOwners.some((owner) => + agentSessionOwnerBindingsEqual(owner, input.durableOwner.binding) + ) + ) { + throw new Error('The resumed terminal lost its durable owner identity.') + } + } + assertDurableOwner() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + assertDurableOwner() + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + return { transcriptPath } + } + + async getStructuredAgentSessionCreateSupport( + worktreeSelector: string, + agent: 'codex' + ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { + const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) + await this.ensureStructuredAgentSessionHost() + if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { + return { supported: true } + } + return { + supported: false, + reason: + location.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'remote' + : location.wslDistro + ? 'wsl' + : 'agent' + } + } + + private hasProviderSessionObservationSource(): boolean { + return ( + this.getAgentProviderSessionRowsForPaneFn !== null || + this.getAgentProviderSessionSnapshotFn !== null + ) + } + + private findAdoptedProviderSession( + paneKey: string, + provider: 'claude' | 'codex', + providerSessionId: string + ): AgentStatusIpcPayload | undefined { + const rows = + this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? + (this.getAgentProviderSessionSnapshotFn?.() ?? []).filter((row) => row.paneKey === paneKey) + return rows + .filter((row) => row.agentType === provider && row.providerSession?.id === providerSessionId) + .reduce( + (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), + undefined + ) + } + + private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { + const target = await this.resolveRuntimeFileTarget(worktreeSelector) + const repo = this.store?.getRepo(target.worktree.repoId) + const wslDistro = + repo && !target.connectionId + ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) + : null + const folderWorkspace = this.store + ?.getFolderWorkspaces?.() + .some((workspace) => workspace.id === target.worktree.id) + return { + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree: target.worktree, + connectionId: target.connectionId + }), + wslDistro, + workspaceId: target.worktree.id, + workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) + } + } + + async resolveStructuredAgentSessionCreateIntent(input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }): Promise { + return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { + // A create has no process yet, so the current selection is what it must follow. + const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + const configuredHome = launchEnv.CODEX_HOME + return ( + preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn ? getSystemCodexHomePath() : configuredHome?.trim()) || + getSystemCodexHomePath() + ) + }) + } + + private async resolveStructuredAgentSessionIntent( + input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }, + resolveAccountHomePath: (context: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | Promise + ): Promise { + const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) + if (!support.supported) { + throw new Error('structured_agent_session_unsupported') + } + const settings = this.requireStore().getSettings() + const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) + const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + return { + envelope: { + sessionId: input.envelope.sessionId, + clientOperationId: input.envelope.clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location, + provider: input.agent, + agent: input.agent, + accountHome: { + variable: 'CODEX_HOME', + path: await resolveAccountHomePath({ workspacePath, launchEnv }) + }, + runtimeKind: 'native' + } + } + + restoreStructuredAgentSessionTabs(): Promise { + this.structuredAgentSessionTabRestorePromise ??= + this.restoreStructuredAgentSessionTabsOnce().catch((error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + }) + return this.structuredAgentSessionTabRestorePromise + } + + prepareStructuredAgentSessionStartupRestoration(): Promise { + this.structuredAgentSessionStartupRestorePromise ??= + this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { + this.structuredAgentSessionStartupRestorePromise = null + throw error + }) + return this.structuredAgentSessionStartupRestorePromise + } + + private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { + if (!this.hasPersistedStructuredAgentSessionStore()) { + return + } + // Durable agent records must exist before daemon inventory can be reconciled against them. + await this.ensureStructuredAgentSessionHost() + await this.refreshMobileSessionPtyRecords() + await getStructuredAgentSessionHost()?.reconcileRestartLeases() + } + + private hasPersistedStructuredAgentSessionStore(): boolean { + return hasPersistedStructuredAgentSessionStoreOnDisk(getProfileUserDataPath()) + } + + private async restoreStructuredAgentSessionTabsOnce(): Promise { + await this.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + const persistedVisibleIndex = + typeof host?.getPersistedVisibleSessionTabIndex === 'function' + ? host.getPersistedVisibleSessionTabIndex() + : { present: false, sessionIds: [] } + const profileIds = collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) + await host?.restoreReadableSessions( + persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds + ) + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + for (const session of host?.listSessionTabs() ?? []) { + if (session.agent !== 'codex') { + continue + } + let sessionId = session.sessionId + while (sessionId.startsWith('agent-session:')) { + sessionId = sessionId.slice('agent-session:'.length) + } + await this.publishStructuredAgentSessionTab({ + ...session, + agent: 'codex', + sessionId, + activate: false, + notify: false + }) + } + } + + async publishStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'codex' + activate: boolean + notify?: boolean + }): Promise { + const host = getStructuredAgentSessionHost() + if (typeof host?.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(input.sessionId, true) + } + const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) + const id = `agent-session:${input.sessionId}` + if (existing?.tabs.some((tab) => tab.id === id)) { + return + } + const tab: RuntimeMobileSessionAgentTab = { + type: 'agent-session', + id, + title: 'Codex Chat', + sessionId: input.sessionId, + agent: input.agent, + isActive: input.activate + } + const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ + ...candidate, + isActive: input.activate ? false : candidate.isActive + })) + tabs.push(tab) + const priorGroups = existing?.tabGroups ?? [ + { + id: this.getHeadlessMobileSessionGroupId(input.workspaceId), + activeTabId: existing?.activeTabId ?? null, + tabOrder: [] + } + ] + const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId) + ? existing!.activeGroupId! + : priorGroups[0]!.id + const tabGroups = priorGroups.map((group) => + group.id === groupId + ? { + ...group, + activeTabId: input.activate ? id : group.activeTabId, + tabOrder: [...group.tabOrder, id] + } + : group + ) + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: input.workspaceId, + publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), + activeTabId: input.activate ? id : (existing?.activeTabId ?? null), + activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), + tabGroups, + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs + } + this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) + if (input.notify !== false) { + this.emitMobileSessionTabsSnapshot(snapshot) + } + } + + private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ + worktree: ResolvedWorktree + repo?: Repo + connectionId?: string + localGitOptions?: { wslDistro?: string } + }> { + const store = this.requireStore() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = store.getRepo(worktree.repoId) + const connectionId = repo?.connectionId ?? undefined + const localGitOptions = + repo && !connectionId ? getLocalProjectWorktreeGitOptions(store, repo) : {} + return { worktree, repo, connectionId, localGitOptions } + } + + private async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ + worktree: ResolvedWorktree + connectionId?: string + }> { + const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) + if (folderScope?.folderWorkspace) { + return { + worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace), + connectionId: folderScope.connectionId ?? undefined + } + } + + const store = this.requireStore() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = store.getRepo(worktree.repoId) + return { worktree, connectionId: repo?.connectionId ?? undefined } + } + + private async resolveKnownWorkspaceFileTarget( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<{ + worktree: ResolvedWorktree + connectionId?: string + relativePath: string + } | null> { + const targets = new Map< + string, + { + worktree: ResolvedWorktree + connectionId?: string + executionHostId: ExecutionHostId + } + >() + const resolvedWorktrees = await this.listResolvedWorktrees() + const settings = this.store?.getSettings() + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolvedWorktrees, + settings?.worktreeVisibilityDefaults + ) + for (const worktree of resolvedWorktrees) { + if ( + !this.isRuntimeWorktreeVisible( + worktree, + visibilitySourceMatchersByRepoId.get(worktree.repoId), + settings + ) + ) { + continue + } + const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } + for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { + try { + const candidateConnectionId = + this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined + const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace) + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } catch { + // An ambiguous folder workspace has no single filesystem authority. + } + } + + const owner = findRuntimeWorkspaceFileOwner( + [...targets.values()].map((target) => ({ + workspaceId: target.worktree.id, + rootPath: target.worktree.path, + executionHostId: target.executionHostId + })), + absolutePath, + executionHostId + ) + if (!owner) { + return null + } + const target = targets.get(`${owner.executionHostId}\0${owner.workspaceId}`) + return target ? { ...target, relativePath: owner.relativePath } : null + } + + onMobileSessionTabsChanged( + listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void, + clientNavigationId?: string + ): () => void { + // Why: a notify coalesced before this subscriber existed is already folded + // into the initial snapshot it was just sent. Draining it here — before the + // listener joins — keeps that pending timer from landing as a redundant + // `updated` frame carrying pre-subscribe state. Mirrors the unsubscribe flush. + this.mobileSessionTabsNotifyCoalescer.flushAll() + const subscription = { listener, clientNavigationId } + this.mobileSessionTabListeners.add(subscription) + return () => { + // Why: flush pending coalesced notifies before dropping this listener so a + // subscriber closing mid-window still receives the latest settled state. + this.mobileSessionTabsNotifyCoalescer.flushAll() + this.mobileSessionTabListeners.delete(subscription) + if (this.mobileSessionTabListeners.size === 0) { + this.mobileSessionTabsAgentStatusHeartbeat.cancelPending() + } + } + } + + forgetClientNavigationState(clientNavigationId: string): void { + this.clientSessionTabSelections.forgetClient(clientNavigationId) + } + + // Why: terminal handles are normally created lazily when first referenced via + // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE + // env var) so they can self-identify in orchestration messages without an + // extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it. + preAllocateHandleForPty(ptyId: string): string { + const existing = this.handleByPtyId.get(ptyId) + if (existing) { + return existing + } + const handle = this.createPreAllocatedTerminalHandle() + this.handleByPtyId.set(ptyId, handle) + return handle + } + + createPreAllocatedTerminalHandle(): string { + return `term_${randomUUID()}` + } + + private rememberPtyHandleReplacementFence( + ptyId: string, + incarnationId: PtyIncarnationId, + staleHandles: Iterable, + pendingRegistration: boolean + ): void { + const previous = this.pendingPtyHandleReplacementFences.get(ptyId) + const merged = new Set(previous?.staleHandles) + for (const handle of staleHandles) { + merged.add(handle) + } + // A PTY normally has one direct and one renderer alias. Keep a small bound + // in case a malformed provider emits an unbounded alias stream. + while (merged.size > 16) { + const oldest = merged.values().next().value + if (typeof oldest !== 'string') { + break + } + merged.delete(oldest) + } + this.pendingPtyHandleReplacementFences.set(ptyId, { + incarnationId, + staleHandles: merged, + pendingRegistration + }) + } + + registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { + if (this.pendingPtyHandleReplacementFences.get(ptyId)?.staleHandles.has(handle)) { + // The provider can replay the old env handle after announcing a new + // incarnation. Never let that predecessor alias be reintroduced. + return + } + const retained = this.handleByPtyIncarnation.get(ptyId) + if (retained?.handle === handle) { + this.handleByPtyIncarnation.delete(ptyId) + } else { + this.invalidatePtyIncarnationHandle(ptyId) + } + this.handleByPtyId.set(ptyId, handle) + for (const leaf of this.getLeavesForPty(ptyId)) { + this.adoptPreAllocatedHandle(leaf) + } + } + + private adoptControllerTerminalHandle( + ptyId: string, + handle: string | undefined, + incarnationId?: string, + options: { exactRestoredSurface?: boolean } = {} + ): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + const pty = this.ptysById.get(ptyId) + const changedIncarnation = Boolean( + incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId + ) + if (changedIncarnation) { + const priorHandle = this.handleByPtyId.get(ptyId) + this.invalidateAllHandlesForPty(ptyId) + pty!.tabId = null + pty!.paneKey = null + // Reusing an exported handle would make stale client metadata name the replacement process. + if (priorHandle === trimmed) { + return + } + } + if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { + if ( + !options.exactRestoredSurface || + !this.replaceSyntheticTerminalHandlesForRestoredPty(ptyId, trimmed) || + this.isTerminalHandleAdoptionBlocked(ptyId, trimmed) + ) { + return + } + } + // Why: after an app/runtime restart, the live PTY child still has its + // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. + this.registerPreAllocatedHandleForPty(ptyId, trimmed) + } + + private invalidateAllHandlesForPty(ptyId: string, preserveHandle?: string): Set { + const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle + const preallocatedHandle = this.handleByPtyId.get(ptyId) + const invalidated = new Set() + if (incarnationHandle && incarnationHandle !== preserveHandle) { + this.handleByPtyIncarnation.delete(ptyId) + invalidated.add(incarnationHandle) + } else if (incarnationHandle) { + // The retained handle no longer describes the old incarnation. Keep its direct alias, + // when requested, but discard the incarnation-specific leaf record. + this.handleByPtyIncarnation.delete(ptyId) + } + if (preallocatedHandle && preallocatedHandle !== preserveHandle) { + this.handleByPtyId.delete(ptyId) + invalidated.add(preallocatedHandle) + } + for (const [handle, record] of this.handles) { + if (record.ptyId === ptyId && handle !== preserveHandle) { + invalidated.add(handle) + } + } + for (const handle of invalidated) { + this.handles.delete(handle) + this.syntheticTerminalHandles.delete(handle) + this.rejectWaitersForHandle(handle, 'terminal_handle_stale') + } + for (const [leafKey, handle] of this.handleByLeafKey) { + if (invalidated.has(handle) || (preserveHandle !== undefined && handle === preserveHandle)) { + this.handleByLeafKey.delete(leafKey) + } + } + if (preserveHandle !== undefined) { + // The direct alias is the only identity retained across an incarnation + // change. Renderer records point at the predecessor pane generation and + // must be rebuilt by graph sync (or issuePtyHandle) before use. + this.handles.delete(preserveHandle) + this.syntheticTerminalHandles.delete(preserveHandle) + } + return invalidated + } + + private replaceSyntheticTerminalHandlesForRestoredPty( + ptyId: string, + controllerHandle: string + ): boolean { + const boundHandles = new Set() + const directHandle = this.handleByPtyId.get(ptyId) + if (directHandle) { + boundHandles.add(directHandle) + } + for (const [handle, record] of this.handles) { + if (record.ptyId === ptyId) { + boundHandles.add(handle) + } else if (handle === controllerHandle) { + return false + } + } + for (const [otherPtyId, handle] of this.handleByPtyId) { + if (otherPtyId !== ptyId && handle === controllerHandle) { + return false + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + boundHandles.add(handle) + } + } + if ( + boundHandles.size === 0 || + [...boundHandles].some( + (handle) => handle === controllerHandle || !this.syntheticTerminalHandles.has(handle) + ) + ) { + return false + } + this.invalidateAllHandlesForPty(ptyId) + return true + } + + // Why: adoption is best-effort restart recovery and must be first-wins. + // Re-keying a pty that already has a handle this session would strand + // waiters registered under the old handle, and provider-reported values + // are not trusted to be collision-free — a handle bound to a different + // pty must never be stolen by a later report. + private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { + if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { + return true + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (issued && issued !== handle) { + return true + } + } + const existingRecord = this.handles.get(handle) + if (existingRecord && existingRecord.ptyId !== ptyId) { + return true + } + for (const [otherPtyId, otherHandle] of this.handleByPtyId) { + if (otherHandle === handle && otherPtyId !== ptyId) { + return true + } + } + return false + } + + onPtySpawned( + ptyId: string, + incarnationId?: PtyIncarnationId, + options: { awaitsRegistration?: boolean } = {} + ): void { + const existingPty = this.ptysById.get(ptyId) + if ( + existingPty && + incarnationId !== undefined && + existingPty.incarnationId !== null && + existingPty.incarnationId !== incarnationId + ) { + // Providers announce a child before the commit binds its pane. Fence the + // predecessor now so a reused id cannot route through its old handle in + // that gap. + this.rememberPtyHandleReplacementFence( + ptyId, + incarnationId, + this.invalidateAllHandlesForPty(ptyId), + true + ) + } + this.forgetPtyLivenessVerdict(ptyId) + if (options.awaitsRegistration !== false) { + // Why: surface absence cannot distinguish an in-flight admission from a completed headless lifecycle. + this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) + } + this.spawnPublishedPtys.add(ptyId) + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + if (incarnationId) { + pty.incarnationId = incarnationId + } + pty.connected = true + pty.disconnectedAt = null + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + this.adoptPreAllocatedHandle(leaf) + } + } + + registerPty( + ptyId: string, + worktreeId: string, + connectionId: string | null = null, + binding?: { + tabId: string + leafId: string + incarnationId?: PtyIncarnationId + /** Handle allocated for the replacement incarnation, when one is known. */ + terminalHandle?: string + agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } + providerReattachLaunchIdentity?: { + incarnationId: PtyIncarnationId + launchAgent: TuiAgent + } + }, + isWsl?: boolean + ): void { + this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) + const existingPty = this.ptysById.get(ptyId) + const replacementHandle = binding?.terminalHandle?.trim() + const pendingReplacement = this.pendingPtyHandleReplacementFences.get(ptyId) + const pendingReplacementMatches = + pendingReplacement !== undefined && + pendingReplacement.pendingRegistration && + binding?.incarnationId !== undefined && + pendingReplacement.incarnationId === binding.incarnationId + const incarnationChanged = + existingPty !== undefined && + binding?.incarnationId !== undefined && + existingPty.incarnationId !== null && + existingPty.incarnationId !== binding.incarnationId + if (incarnationChanged || pendingReplacementMatches) { + // A reconnect can register a replacement before inventory reports its exported handle. + // Drop every alias for the predecessor; a newly preallocated handle is retained only when + // the caller can prove it is the replacement's handle. + const directHandle = this.handleByPtyId.get(ptyId) + const canPreserveReplacementHandle = + replacementHandle !== undefined && + replacementHandle.startsWith('term_') && + directHandle === replacementHandle && + !pendingReplacement?.staleHandles.has(replacementHandle) + const invalidated = this.invalidateAllHandlesForPty( + ptyId, + canPreserveReplacementHandle ? replacementHandle : undefined + ) + if (binding?.incarnationId) { + this.rememberPtyHandleReplacementFence( + ptyId, + binding.incarnationId, + invalidated, + pendingReplacementMatches + ) + } + } + this.forgetPtyLivenessVerdict(ptyId) + this.spawnPublishedPtys.add(ptyId) + // Why: record the renderer pane identity at spawn time so a stalled graph + // sync can't hide that a live PTY already backs a pending mobile create. + const paneKey = + binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId) + ? makePaneKey(binding.tabId, binding.leafId) + : null + const pty = this.recordPtyWorktree(ptyId, worktreeId, { + connected: true, + connectionId, + ...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`) + ? { runtimeSessionOwned: true } + : {}), + ...(isWsl !== undefined ? { isWsl } : {}), + ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), + ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) + }) + const agentLaunchAuthority = binding?.agentLaunchAuthority + if ( + agentLaunchAuthority && + paneKey && + binding.incarnationId && + pty.incarnationId === binding.incarnationId && + pty.paneKey === paneKey && + pty.launchToken === null && + agentLaunchAuthority.launchToken.length > 0 && + agentLaunchAuthority.launchToken.length <= 128 && + isTuiAgent(agentLaunchAuthority.launchAgent) + ) { + pty.launchToken = agentLaunchAuthority.launchToken + pty.launchIncarnationId = binding.incarnationId + pty.launchAgent = agentLaunchAuthority.launchAgent + } + const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity + if ( + providerReattachLaunchIdentity && + paneKey && + binding.incarnationId === providerReattachLaunchIdentity.incarnationId && + pty.incarnationId === providerReattachLaunchIdentity.incarnationId && + pty.paneKey === paneKey && + isTuiAgent(providerReattachLaunchIdentity.launchAgent) + ) { + // Why: daemon metadata owns the surviving process; its incarnation fence restores identity without minting renderer launch authority. + pty.launchAgent = providerReattachLaunchIdentity.launchAgent + } + const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) + if ( + pendingIncarnation === null || + pendingIncarnation === undefined || + binding?.incarnationId === undefined || + pendingIncarnation === binding.incarnationId + ) { + this.pendingPtyRegistrationIncarnations.delete(ptyId) + } + if (pendingReplacement !== undefined) { + const currentFence = this.pendingPtyHandleReplacementFences.get(ptyId) + if (currentFence && (pendingReplacementMatches || !binding?.incarnationId)) { + currentFence.pendingRegistration = false + } + } + // Why: the renderer's own PTY spawn is the reliable signal that the pending + // mobile create's tab is live; publish its surface main-side (#7587). + if (binding && paneKey) { + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) + } + } + + assertPtyRegistrationAllowed(ptyId: string, incarnationId?: PtyIncarnationId): void { + // Why: the controller must reject an early exit before persisting bindings or handles. + this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) + } + + releaseRejectedPtyRegistrationFence( + ptyId: string, + candidateIncarnation?: PtyIncarnationId + ): void { + if (!this.earlyExitedPtyIncarnations.has(ptyId)) { + return + } + const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null + if ( + exitedIncarnation === null || + candidateIncarnation === undefined || + exitedIncarnation === candidateIncarnation + ) { + // Why: the rejected spawn call was the fence's sole late publisher; retaining it leaks fresh PTY ids. + this.earlyExitedPtyIncarnations.delete(ptyId) + this.pendingPtyRegistrationIncarnations.delete(ptyId) + } + } + + beginPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { + this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) + } + + acceptPtyIncarnationForExit(ptyId: string, incarnationId: PtyIncarnationId): void { + const pty = this.ptysById.get(ptyId) + if (pty) { + // Why: a reconnect attach reply can prove the exit generation after stale local proof was cleared. + pty.incarnationId = incarnationId + } + } + + cancelPendingPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { + const pending = this.pendingPtyRegistrationIncarnations.get(ptyId) + if ( + !this.pendingPtyRegistrationIncarnations.has(ptyId) || + (pending !== null && incarnationId !== undefined && pending !== incarnationId) + ) { + return + } + this.pendingPtyRegistrationIncarnations.delete(ptyId) + const exited = this.earlyExitedPtyIncarnations.get(ptyId) + if ( + exited === null || + exited === undefined || + incarnationId === undefined || + exited === incarnationId + ) { + this.earlyExitedPtyIncarnations.delete(ptyId) + } + } + + private assertPtyDidNotExitBeforeRegistration( + ptyId: string, + candidateIncarnation?: PtyIncarnationId + ): void { + if (this.earlyExitedPtyIncarnations.has(ptyId)) { + const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null + const nextIncarnation = candidateIncarnation ?? null + if ( + exitedIncarnation === null || + nextIncarnation === null || + exitedIncarnation === nextIncarnation + ) { + throw new Error('agent_session_exited_during_start') + } + this.earlyExitedPtyIncarnations.delete(ptyId) + } + } + + preparePtyExecutionContext( + ptyId: string, + wslDistro: string | null, + options: { resetIncarnation?: boolean; preserveExisting?: boolean } = {} + ): boolean { + const pty = this.ptysById.get(ptyId) + const hadExistingContext = this.wslDistroByPtyId.has(ptyId) || pty !== undefined + if (options.preserveExisting && hadExistingContext) { + // Why: attach-time settings are only a fallback; a live PTY's recorded + // execution namespace remains authoritative until its provider replies. + return false + } + + if (options.resetIncarnation) { + // Why: an explicit new lifecycle supersedes an unidentifiable exit from the reused PTY id. + this.earlyExitedPtyIncarnations.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + } + + const previous = this.wslDistroByPtyId.get(ptyId) ?? null + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + if (pty) { + pty.wslDistro = wslDistro + } + if (!options.resetIncarnation && previous !== wslDistro && this.headlessTerminals.has(ptyId)) { + // Why: bytes parsed with two distro namespaces would leave an internally + // inconsistent CWD; rebuild from the provider's authoritative snapshot. + this.terminalCwdByPtyId.delete(ptyId) + this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId) + } + return options.resetIncarnation === true || !hadExistingContext || previous !== wslDistro + } + + /** Record the spawn launch command so the per-PTY Command Code detector can + * arm from it (renderer startupCommand parity). Best-effort: a chunk that + * beats this call falls back to the detector's banner arming. */ + noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { + const trimmed = typeof command === 'string' ? command.trim() : '' + if (trimmed.length > 0) { + this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) + } + } + + resetPtyModelAfterMigrationFailure(ptyId: string): void { + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + } + + /** + * Handles incoming data from a PTY process, running agent detection, + * updating terminal tail buffers, and triggering foreground agent refreshes. + */ + acceptPtyDataBounded( + ptyId: string, + data: string, + at: number, + sequenceChars = data.length, + transformed = false, + sourceRanges?: readonly TerminalOutputSourceRange[] + ): RuntimePtyDataAdmission { + let completion: Promise | null = null + const sequence = this.onPtyData( + ptyId, + data, + at, + sequenceChars, + transformed, + (receipt) => { + completion = receipt + }, + sourceRanges + ) + if (!completion) { + throw new Error('PTY model admission receipt was not captured') + } + return Object.freeze({ sequence, completion }) + } + + onPtyData( + ptyId: string, + data: string, + at: number, + sequenceChars = data.length, + transformed = false, + captureModelReceipt?: (completion: Promise) => void, + sourceRanges?: readonly TerminalOutputSourceRange[] + ): number { + this.invalidatePtyLivenessSnapshot() + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars + this.ptyOutputSequenceById.set(ptyId, outputSequence) + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } + const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) + const cwd = osc7Metadata.cwd + const cwdChanged = osc7Metadata.cwdChanged + const agentStatusChunk = this.processAgentStatusOscForPty(ptyId, data) + this.recordRecentPtyOutputForPathProvenance(ptyId, data) + // Why: watch terminal output for advertised dev-server URLs (e.g. Vite's + // `Network: https://local.example.com:3001/`) so the workspace ports + // panel can surface them in place of the kernel bind address. + advertisedUrlWatcher.ingest(ptyId, data, at) + // Why: reply ownership is captured per chunk, here at ingestion — the + // same module state and tick as the hidden-gate drop sites — and rides + // the writeChain link. A mark/setting/subscriber flip before the queued + // emulator write runs must not change who answers (terminal-query- + // authority.md invariant 1). + const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) + // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer + // MUST run before trackHeadlessTerminalData so the eager-state pattern + // (set headlessTerminals + writeChain head = seedPromise) is in place + // before the live byte's chain link is queued. Without this ordering, + // trackHeadlessTerminalData would lazy-create a fresh state at PTY dims + // that the later seed-resolve would overwrite, dropping the live byte. + // See docs/mobile-prefer-renderer-scrollback.md. + this.maybeHydrateHeadlessFromRenderer(ptyId) + // Our structure wins: OSC title/agent-status extraction runs through the + // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → + // applyTrackedPtyTitle) in byte order, superseding main's inline + // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are + // preserved via the tracker + detectAgentStatusFromTitle path). + const modelCompletion = this.trackHeadlessTerminalData( + ptyId, + data, + outputSequence, + forwardQueryReplies + ) + captureModelReceipt?.(modelCompletion) + + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + const ptyTailBefore = pty + ? { + lines: pty.tailBuffer, + transcriptLines: pty.tailTranscriptBuffer, + partialLine: pty.tailPartialLine, + pendingAnsi: pty.tailPendingAnsi, + redrawCursor: pty.tailRedrawCursor, + truncated: pty.tailTruncated, + linesTotal: pty.tailLinesTotal + } + : null + let ptyTailAfter: ReturnType | null = null + if (pty) { + pty.connected = true + pty.disconnectedAt = null + pty.lastOutputAt = at + const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) + pty.tailPendingAnsi = normalized.pendingAnsi + const nextTail = appendNormalizedToTailBuffer( + pty.tailBuffer, + pty.tailPartialLine, + normalized.text, + pty.tailRedrawCursor + ) + ptyTailAfter = nextTail + const nextTranscript = appendCompletedTerminalTranscript( + pty.tailTranscriptBuffer, + pty.tailTranscriptChars, + nextTail.newlyCompletedLines, + nextTail.newCompleteLines + ) + pty.tailBuffer = nextTail.lines + pty.tailTranscriptBuffer = nextTranscript.lines + pty.tailTranscriptChars = nextTranscript.characters + pty.tailPartialLine = nextTail.partialLine + pty.tailRedrawCursor = nextTail.redrawCursor + pty.tailTruncated = pty.tailTruncated || nextTail.truncated || nextTranscript.truncated + pty.tailLinesTotal += nextTail.newCompleteLines + pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) + this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) + } + + for (const leaf of this.getLeavesForPty(ptyId)) { + this.recordPtyWorktree(ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: pty?.lastOutputAt ?? at, + preview: pty?.preview ?? leaf.preview, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf) + }) + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + leaf.lastOutputAt = at + if ( + pty && + ptyTailBefore && + ptyTailAfter && + tailStateMatches( + leaf.tailBuffer, + leaf.tailTranscriptBuffer, + leaf.tailPartialLine, + leaf.tailPendingAnsi, + leaf.tailRedrawCursor, + leaf.tailTruncated, + leaf.tailLinesTotal, + ptyTailBefore + ) + ) { + // Why: the leaf and PTY record usually mirror the same terminal. Reuse + // the PTY tail update instead of splitting large output twice. + leaf.tailBuffer = pty.tailBuffer + leaf.tailTranscriptBuffer = pty.tailTranscriptBuffer + leaf.tailTranscriptChars = pty.tailTranscriptChars + leaf.tailPartialLine = pty.tailPartialLine + leaf.tailPendingAnsi = pty.tailPendingAnsi + leaf.tailRedrawCursor = pty.tailRedrawCursor + leaf.tailTruncated = pty.tailTruncated + leaf.tailLinesTotal = pty.tailLinesTotal + leaf.preview = pty.preview + leaf.waitBlockedAt = pty.waitBlockedAt + // Why undefined on this branch: the PTY record's wait scan is throttled + // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; + // copying it here intentionally invalidates the leaf cache and the + // mismatch branch below recomputes an exact state on its next chunk. + leaf.tailWaitState = pty.tailWaitState + } else { + const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) + leaf.tailPendingAnsi = normalized.pendingAnsi + const previousWaitState = + leaf.tailWaitState?.fromTail === true + ? leaf.tailWaitState + : computeTerminalTailWaitState(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + const nextTail = appendNormalizedToTailBuffer( + leaf.tailBuffer, + leaf.tailPartialLine, + normalized.text, + leaf.tailRedrawCursor + ) + const nextTranscript = appendCompletedTerminalTranscript( + leaf.tailTranscriptBuffer, + leaf.tailTranscriptChars, + nextTail.newlyCompletedLines, + nextTail.newCompleteLines + ) + const nextWaitState = computeTerminalTailWaitState( + nextTail.lines, + nextTail.partialLine, + leaf.preview + ) + if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { + leaf.waitBlockedAt = at + } + leaf.tailWaitState = nextWaitState + leaf.tailBuffer = nextTail.lines + leaf.tailTranscriptBuffer = nextTranscript.lines + leaf.tailTranscriptChars = nextTranscript.characters + leaf.tailPartialLine = nextTail.partialLine + leaf.tailRedrawCursor = nextTail.redrawCursor + leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated || nextTranscript.truncated + leaf.tailLinesTotal += nextTail.newCompleteLines + leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) + } + } + + // Why: feed the chunk's OSC titles through the shared per-PTY tracker in + // byte order — the same ordering the renderer transport uses — so + // coalesced working→idle transitions reach tui-idle waiters and + // pending-message delivery instead of being masked by the chunk's last + // title (issue #1083). Uses the OSC 9999-stripped cleanData like the + // renderer, so pure status chunks don't perturb the stale-title probe. + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) + const titleInput = previousTitleScanTail + ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` + : agentStatusChunk.cleanData + const nextTitleScanTail = extractOscTitleScanTail(titleInput) + if (nextTitleScanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + titleTrackerEntry.applyingChunk = true + titleTrackerEntry.chunkTouchedSessionTabs = false + let retainedAgentStatusChanged = false + try { + for (const payload of agentStatusChunk.payloads) { + titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) + } + titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { + titleScanData: titleInput + }) + // Why: the Command Code scrape rides the same per-chunk batch (its facts + // trail the tracker's). cleanData keeps OSC 9999 payloads out of the + // detector's bounded recent-text window; the detector strips remaining + // control sequences itself, exactly like the renderer byte path. + titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) + } finally { + titleTrackerEntry.applyingChunk = false + try { + // Why: per-chunk cross-channel contract order is status → titles → + // bell — the chunk's agentStatus:set events must reach the renderer + // before its pty:sideEffect batch. + retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + const lastPayloadTitleOffset = + agentStatusChunk.lastPayloadCleanOffset === null + ? null + : (previousTitleScanTail?.length ?? 0) + agentStatusChunk.lastPayloadCleanOffset + this.restoreAgentPromptLifecycleByteOrder(ptyId, titleInput, lastPayloadTitleOffset) + } finally { + // Why: flushed in the finally so a throwing tracker callback cannot + // strand this chunk's facts to be emitted under the next chunk's seq. + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + } + } + // Why: hook (OSC 9999) transitions often arrive without a title change, so + // headless-serve snapshots would never republish and paired remote clients + // kept the stale agent state until the next title change (#7970). + if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + + const listeners = this.dataListeners.get(ptyId) + if (listeners) { + const meta = { + seq: outputSequence, + rawLength: sequenceChars, + ...(transformed ? { transformed: true } : {}), + ...(cwdChanged && cwd !== null ? { cwd } : {}), + ...(sourceRanges && sourceRanges.length > 0 ? { sourceRanges } : {}) + } + for (const listener of listeners) { + try { + listener(data, meta) + } catch (error) { + // Why: inlined rather than via notifyRuntimeListeners to avoid a per-chunk closure + // allocation on the terminal-output hot path; isolation semantics match the helper. + console.error('[runtime] pty-data listener threw', error) + } + } + } + return outputSequence + } + + private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + const appendedLower = appendedText.toLowerCase() + const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) + state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) + // Why the cap keeps the tail: the accumulated text only anchors boundary- + // spanning prompt detection; anything past the tail cap has scrolled out + // of the retained tail the check reads anyway. + state.appended = + state.appended.length + appendedText.length > MAX_TAIL_CHARS + ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) + : `${state.appended}${appendedText}` + const elapsed = at - state.lastAt + if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { + this.runWaitBlockedCheck(ptyId, state, at) + return + } + if (!state.timer) { + // Why trailing edge: the final chunks of a burst must still be + // evaluated or a prompt arriving right after a flood would go + // unstamped until the next output. + state.timer = setTimeout(() => { + state.timer = null + this.runWaitBlockedCheck(ptyId, state, Date.now()) + }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) + } + } + + private runWaitBlockedCheck( + ptyId: string, + state: { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + }, + at: number + ): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + state.appended = '' + return + } + const nextWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + const previousWaitState = state.lastWaitState ?? { + waitText: '', + signal: null, + fromTail: false + } + if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { + pty.waitBlockedAt = at + this.recordAgentPromptPermissionObservation(ptyId) + } + state.lastAt = at + state.lastWaitState = nextWaitState + state.appended = '' + } + + // Why: the scanner's first run after a restore seed compares against a null + // baseline, so a permission prompt visible only in seeded HISTORY would read + // as newly gained and stamp waitBlockedAt "now" on the next benign chunk. + // Store the seeded tail's wait state as the baseline WITHOUT stamping; only + // a signal that appears in genuinely new output counts as gained. + private primeWaitBlockedBaselineFromSeededTail(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + return + } + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + if (state.lastWaitState === null) { + state.lastWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + } + } + + private clearWaitBlockedCheckState(ptyId: string): void { + const state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.waitBlockedCheckStateByPtyId.delete(ptyId) + } + + private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { + let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) + if (!processor) { + processor = createAgentStatusOscProcessor() + this.agentStatusOscProcessorsByPtyId.set(ptyId, processor) + } + return processor(data) + } + + /** Emit the facts batched while applying one chunk/frame as a single + * pty:sideEffect batch, preserving byte order. */ + private flushPendingTerminalSideEffectFacts( + ptyId: string, + entry: RuntimePtyTitleTrackerEntry + ): void { + if (entry.pendingFacts.length === 0) { + return + } + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + + /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through + * the per-PTY tracker — NOT onPtyData, so emulator state, tails, + * transcripts, and stats never see synthetic bytes. Parsed via the + * tracker's stateless synthetic path: the shared chunk bell detector must + * never observe fabricated bytes, or a tick interleaved with a split real + * OSC corrupts its escape state (phantom/swallowed bells). While the + * side-effect kill switch is off the legacy pty:data copy still drives + * renderer parsers; this ingest keeps main's facts and records + * authoritative. */ + ingestSyntheticTitleFrame(ptyId: string, data: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.applyingChunk = true + entry.chunkTouchedSessionTabs = false + try { + entry.tracker.applySyntheticTitleFrame(data) + } finally { + entry.applyingChunk = false + this.flushPendingTerminalSideEffectFacts(ptyId, entry) + } + if (entry.chunkTouchedSessionTabs) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + + /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail + * thinning): while delegated, the daemon relays bell/133/pr-link/2031 + * facts itself and the delivered bytes may be gapped — feeding them to + * main's transient scanners would mint phantom or duplicate facts. Title + * processing stays main-side either way. */ + setPtyTransientFactDelegation( + ptyId: string, + delegated: boolean, + scanSeedAnsi?: string, + mode2031PendingSubscribe?: true + ): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.tracker.setTransientFactScanningSuppressed(delegated) + if (!delegated && scanSeedAnsi) { + // Prime the freshly reset scanner carry with the emulator's dangling + // incomplete escape at the handoff position — a sequence split across + // the un-background toggle must not mint a phantom bell or lose its + // fact. titleScanData:'' keeps titles out (they were never suppressed). + entry.tracker.handleChunk(scanSeedAnsi, { + titleScanData: '', + mode2031PendingSubscribe + }) + } + } + + /** A transient fact the daemon detected while it held scan authority — + * emitted through the same fact channel as byte-scanned facts. Arrives + * between chunks, so recordTerminalSideEffectFact emits it immediately. */ + emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { + switch (fact.kind) { + case 'bell': + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + return + case 'command-finished': + this.retirePtyAgentLaunchAuthority(ptyId) + this.recordTerminalSideEffectFact(ptyId, { + kind: 'command-finished', + exitCode: fact.exitCode + }) + return + case 'pr-link': + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) + return + case '2031-subscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + return + case '2031-unsubscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + } + } + + /** The daemon keep-tail dropped this PTY's oldest undelivered output; the + * next delivered chunk is discontinuous. Reset every cross-chunk parse + * carry so a half-open escape from before the gap cannot corrupt what + * follows, and drop the mobile headless mirror — it rebuilds from the + * delivered tail / snapshot seeds instead of parsing a gapped stream. */ + notePtyDataGap(ptyId: string, droppedChars = 0): void { + if (droppedChars > 0) { + // Why: the daemon snapshot's seq counts bytes its monitoring stream + // dropped. Advancing without parsing preserves that absolute domain so + // post-snapshot live chunks can be reconciled instead of duplicated. + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars + this.ptyOutputSequenceById.set(ptyId, outputSequence) + } + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.tailPendingAnsi = '' + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.tailPendingAnsi = '' + } + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + } + + /** Record one derived side-effect fact: batched per chunk while applying + * bytes, emitted immediately for between-chunk facts (stale-title timer). */ + private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + if (!this.terminalSideEffectConsumerAvailable) { + return + } + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.pendingFacts.push(fact) + return + } + this.emitTerminalSideEffectBatch(ptyId, [fact]) + } + + private emitTerminalSideEffectBatch( + ptyId: string, + facts: TerminalSideEffectFact[], + options: { replay?: boolean } = {} + ): void { + if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) { + return + } + const batch: TerminalSideEffectBatch = { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + facts, + ...(options.replay ? { replay: true } : {}), + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + if (this.terminalSideEffectLocalConsumerAvailable) { + try { + this.onTerminalSideEffects?.(batch) + } catch (err) { + console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) + } + } + if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { + this.emitClientEvent({ type: 'terminalSideEffects', batch }) + } + } + + /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the + * first mounted leaf, fall back to the spawn-time PTY record binding. */ + private resolveTerminalSideEffectAttribution(ptyId: string): { + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null + } { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + return { + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf), + connectionId + } + } + if (pty?.paneKey) { + return { + worktreeId: pty.worktreeId, + ...(pty.tabId ? { tabId: pty.tabId } : {}), + paneKey: pty.paneKey, + connectionId + } + } + return {} + } + + /** Title-only replay batch for renderer (re)attach — the no-attention-replay + * rule: snapshots restore title state, never historical bells/completions. */ + getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { + const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + // Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a + // tracker title Orca synthesized from hooks — but with no tracker title it is the pane's + // only Cursor identity, so restored/mobile tabs keep it (#10258). + const rawTitle = + recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle)) + ? recordTitle + : null + if (normalizedTitle === null && !rawTitle) { + return null + } + return { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + replay: true, + facts: [ + { + kind: 'title', + normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), + rawTitle: rawTitle ?? normalizedTitle! + } + ], + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + } + + /** Raw last title from main's tracked PTY/leaf records — the title surface + * the tracker (live bytes + synthetic frames) keeps current. */ + private getTrackedRawTitleForPty(ptyId: string): string | null { + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + if (recordTitle) { + return recordTitle + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + return leaf.lastOscTitle + } + } + return null + } + + private isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean { + return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true + } + + /** Display fallback for identities intentionally omitted from liveness records. */ + private getTrackedDisplayTitleForPty(ptyId: string): string | null { + return ( + this.getTrackedRawTitleForPty(ptyId) ?? + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? + null + ) + } + + private getUnpersistedTrackedTitleForPty(ptyId: string | null): string | null { + if (!ptyId || this.getTrackedRawTitleForPty(ptyId) !== null) { + return null + } + // Why: a manual title is authoritative until explicitly cleared with null. + const pty = this.ptysById.get(ptyId) + if (pty && pty.title !== null) { + return null + } + return this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null + } + + /** Why: synthetic agent title frames no longer ride pty:data, so neither + * renderer xterm nor the headless emulator observes them. Mobile-parity + * snapshot titles must prefer main's tracker over snapshot lastTitle, or + * hook-driven spinner/idle titles vanish from mobile tabs. */ + private preferTrackedLastTitle(ptyId: string, snapshot: T): T { + const tracked = this.getTrackedDisplayTitleForPty(ptyId) + if (!tracked) { + return snapshot + } + return { ...snapshot, lastTitle: tracked } + } + + /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ + private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { + // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. + const normalizedSignature = + rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) + const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) + return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` + } + + private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + const existing = this.ptyTitleTrackersByPtyId.get(ptyId) + if (existing) { + return existing + } + // Why: trackers are created lazily on the first observed chunk. After an + // app relaunch the PTY/leaf records can already hold a persisted title; a + // cold tracker would miss the parked working→idle completion and never + // arm the stale-title timer for a persisted 'working' title. + let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + if (initialTitle === null) { + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + initialTitle = leaf.lastOscTitle + break + } + } + } + const tracker = createTerminalTitleTracker( + { + onTitle: (normalizedTitle, rawTitle, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) + const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (live) { + live.lastMobileTitleGateKey = gateKey + } + const tracksReplicatedStatus = + live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 + const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null + if ( + tracksReplicatedStatus && + decorativeOnly && + !this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) && + (titleStatus === 'working' || titleStatus === 'permission') + ) { + // Normalized Pi/Gemini/Grok frames still renew the replicated status lease. + this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId) + } + // Why: an identity-only cursor title records nothing, but the tracker + // title is that pane's only Cursor identity and must still fan out (#10258). + if (!changed && !identityOnlyTitle) { + return + } + if (live?.applyingChunk) { + // Why: synthetic spinner ticks change only the braille glyph + // ~12.5x/sec; fanning out full mobile session snapshots per frame + // is pure churn. Raw lastOscTitle updates above stay cheap. + if (!decorativeOnly) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + live.chunkTouchedSessionTabs = true + } + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }, + // Why: agent transitions and bells become pty:sideEffect facts — + // main is the single byte parser for local/SSH PTYs; the renderer + // store handler decides what the facts mean (notification policy). + onAgentBecameWorking: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) + }, + onAgentBecameIdle: (title, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-idle', + title, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + }, + onAgentExited: () => { + this.confirmPtyAgentExit(ptyId) + }, + onCommandFinished: (exitCode: number | null) => { + this.retirePtyAgentLaunchAuthority(ptyId) + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + }, + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + } + }, + initialTitle !== null ? { initialTitle } : {} + ) + tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) + const entry: RuntimePtyTitleTrackerEntry = { + tracker, + applyingChunk: false, + lastMobileTitleGateKey: null, + chunkTouchedSessionTabs: false, + pendingFacts: [], + // Why: command-code facts exist only for the pty:sideEffect channel — + // headless serve skips the per-chunk scrape entirely. The detector + // self-arms on the Command Code banner; the spawn command (when main + // saw one) mirrors the renderer detector's startupCommand fast-arm. + commandCodeDetector: this.terminalSideEffectConsumerAvailable + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) + : null + } + this.ptyTitleTrackersByPtyId.set(ptyId, entry) + return entry + } + + /** Apply one observed OSC title (raw form) to the PTY and leaf records. + * Returns true when the PTY record's title or status changed. */ + private applyTrackedPtyTitle( + ptyId: string, + rawTitle: string, + normalizedTitle: string, + meta?: TerminalTitleFactMeta + ): boolean { + // Why: status is detected from the RAW title (mirrors the renderer tracker), + // so working/idle transitions are unaffected by normalization; the records + // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to + // one stable stored label (#7880) instead of churning `ps`/mobile tabs. + // + // Why the identity-only case: the bare cursor-agent literal identifies the pane without + // asserting activity, so it records NO title/status evidence — only the tracker keeps it, + // for display (#10258). Nulling the status here rather than trusting the detector keeps + // that contract local, since every activity-gated effect below is keyed on status. + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) + const recordedTitle = identityOnlyTitle ? null : normalizedTitle + const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) + this.recordAgentPromptLifecycleState(ptyId, agentStatus) + let ptyRecordChanged = false + const pty = this.ptysById.get(ptyId) + if (pty) { + const prevStatus = pty.lastAgentStatus + const prevTitle = pty.lastOscTitle + const observedAt = this.nextTitleObservationSequence() + const observedAtEpochMs = identityOnlyTitle ? null : Date.now() + pty.lastOscTitle = recordedTitle + pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt + pty.lastOscTitleEpochMs = observedAtEpochMs + pty.lastAgentStatus = agentStatus + pty.lastAgentStatusObservedLive = true + if (prevStatus !== agentStatus) { + pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs + } + if ( + identityOnlyTitle || + terminalTitleBlocksExplicitAgentStatus(recordedTitle) || + (prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus) + ) { + pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() + } + if (identityOnlyTitle) { + pty.managementTitle = null + pty.managementTitleAt = null + } else { + this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) + } + ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + const shouldDelayMobileSnapshot = + ptyRecordChanged && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) + let foregroundRefresh: Promise | undefined + // Why: gate on an actual status transition — braille spinner frames + // mutate the title every tick, so probing per-title-change would stream + // a foreground query per frame during active work. + if (prevStatus !== agentStatus) { + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + // Why: report "unchanged" so the per-chunk batch skips the mobile + // snapshot fan-out; the delayed publish fires when the probe settles. + ptyRecordChanged = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + const prevStatus = leaf.lastAgentStatus + const prevObservedLive = leaf.lastAgentStatusObservedLive + leaf.lastOscTitle = recordedTitle + leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. + leaf.lastAgentStatus = agentStatus + leaf.lastAgentStatusObservedLive = true + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolveTuiIdleWaiters(leaf) + } + // Why the second condition: push delivery is gated on LIVE idle, so its + // authorizing edge is liveness as well as status. A restore seed or a + // status kept across a same-id respawn leaves a stale 'idle' behind, and + // an agent whose first live title is already idle (claude --resume at its + // prompt) then shows no transition — the row would strand, which is + // exactly #12536. Waiter semantics stay transition-only above. + if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { + this.deliverPendingMessagesForLeaf(leaf) + } + } + return ptyRecordChanged + } + + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY + * teardown so it cannot fire into pruned records. */ + private disposePtyTitleTracker(ptyId: string): void { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() + this.ptyTitleTrackersByPtyId.delete(ptyId) + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) + for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { + titleGateKeys.delete(ptyId) + } + } + + private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { + // Why: a replacement daemon session can reuse the PTY id, but title/parser + // state from the prior process must not bleed into its snapshots or chunks. + this.disposePtyTitleTracker(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.agentPromptLifecycleByPtyId.delete(ptyId) + this.agentPromptPermissionSequenceByPtyId.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.lastOscTitle = null + pty.lastOscTitleAt = null + pty.lastOscTitleEpochMs = null + pty.lastAgentStatus = null + // Why: the prior process's live frames say nothing about the replacement, + // so the seed a same-id restore applies must not inherit its authority. + pty.lastAgentStatusObservedLive = false + pty.lastAgentStatusStartedAtEpochMs = null + pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() + pty.managementTitle = null + pty.managementTitleAt = null + pty.waitBlockedAt = null + pty.tailWaitState = undefined + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.lastOscTitle = null + leaf.lastOscTitleAt = null + leaf.lastAgentStatus = null + leaf.lastAgentStatusObservedLive = false + leaf.waitBlockedAt = null + leaf.tailWaitState = undefined + } + this.primeWaitBlockedBaselineFromSeededTail(ptyId) + this.clearAgentRowSnapshotsForPty(ptyId) + } + + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null + this.refreshTerminalSideEffectConsumerAvailability() + } + + private refreshTerminalSideEffectConsumerAvailability(): void { + const nextAvailable = + this.terminalSideEffectLocalConsumerAvailable || + this.countTerminalSideEffectConsumingClientEventListeners() > 0 + if (nextAvailable === this.terminalSideEffectConsumerAvailable) { + return + } + this.terminalSideEffectConsumerAvailable = nextAvailable + for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { + entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) + entry.commandCodeDetector = nextAvailable + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) + : null + } + } + + private createTerminalSideEffectCommandCodeDetector( + ptyId: string + ): NonNullable { + return createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + } + + private extractLastOsc7CwdForPty( + ptyId: string, + data: string + ): { path: string; hostname: string } | null { + const previousTail = this.osc7ScanTailByPtyId.get(ptyId) + if (!previousTail && !data.includes('\x1b]7;')) { + return null + } + const input = `${previousTail ?? ''}${data}` + const scanTail = extractOscScanTail(input, 4096) + if (scanTail.length > 0) { + this.osc7ScanTailByPtyId.set(ptyId, scanTail) + } else { + this.osc7ScanTailByPtyId.delete(ptyId) + } + const uri = extractLastOsc7Uri(input) + const pty = this.ptysById.get(ptyId) + const pathFlavor = this.pathFlavorForPty(pty) + return uri + ? parseFileUriPathParts(uri, { + pathFlavor, + remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32', + wslDistro: pty?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined) + }) + : null + } + + private recordOsc7MetadataForPty( + ptyId: string, + data: string + ): { cwd: string | null; cwdChanged: boolean } { + const osc7 = this.extractLastOsc7CwdForPty(ptyId, data) + const cwd = osc7?.path ?? null + const cwdChanged = + cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd + if (cwdChanged) { + this.terminalCwdByPtyId.set(ptyId, cwd) + } + if (osc7) { + if (osc7.hostname) { + this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname) + } else { + this.terminalFileUriHostnameByPtyId.delete(ptyId) + } + } + return { cwd, cwdChanged } + } + + private pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' { + if (!pty?.connectionId) { + return process.platform === 'win32' ? 'win32' : 'posix' + } + const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath + return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' + } + + /** Returns true when any retained agent-row snapshot changed in a + * client-visible way, so the caller can republish session snapshots. */ + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { + // Why: snapshot retention (for mobile worktree.ps) must run even when no + // renderer listener is attached, so we don't early-return on a missing + // onTerminalAgentStatus — only the per-target emit below is gated on it. + if (chunk.payloads.length === 0) { + return false + } + const targets = new Map< + string, + { + source: 'mounted-leaf' | 'pty-record' + paneKey: string + tabId?: string + worktreeId?: string + connectionId?: string | null + } + >() + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + const paneKey = this.makeRuntimePaneKey(leaf) + targets.set(paneKey, { + source: 'mounted-leaf', + paneKey, + tabId: leaf.tabId, + worktreeId: leaf.worktreeId, + connectionId + }) + } + if (targets.size === 0 && pty?.paneKey) { + targets.set(pty.paneKey, { + source: 'pty-record', + paneKey: pty.paneKey, + tabId: pty.tabId ?? undefined, + worktreeId: pty.worktreeId, + connectionId + }) + } + let retainedChanged = false + for (const payload of chunk.payloads) { + this.recordAgentPromptLifecycleState( + ptyId, + mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) + ) + for (const target of targets.values()) { + retainedChanged = + this.retainAgentRowSnapshot( + ptyId, + target.paneKey, + target.worktreeId, + target.tabId, + target.connectionId ?? null, + payload + ) || retainedChanged + if (!this.onTerminalAgentStatus) { + continue + } + try { + this.onTerminalAgentStatus({ + ptyId, + ...target, + payload + }) + } catch (err) { + console.error('[runtime] terminal agent status listener threw', { + ptyId, + paneKey: target.paneKey, + state: payload.state, + agentType: payload.agentType, + err + }) + } + } + } + return retainedChanged + } + + private retainAgentRowSnapshot( + ptyId: string, + paneKey: string, + worktreeId: string | undefined, + tabId: string | undefined, + connectionId: string | null, + payload: ParsedAgentStatusPayload + ): boolean { + const now = Date.now() + const previous = this.latestAgentStatusByPaneKey.get(paneKey) + // Why: stateStartedAt must mark the transition into the current state, not + // every within-state ping (tool/prompt updates keep the state but refresh + // updatedAt) — mirrors AgentStatusEntry.stateStartedAt on the desktop side. + const stateStartedAt = + previous && previous.payload.state === payload.state ? previous.stateStartedAt : now + this.latestAgentStatusByPaneKey.set(paneKey, { + paneKey, + ptyId, + worktreeId, + tabId, + connectionId, + payload, + stateStartedAt, + updatedAt: now + }) + // Client-visible change detection: snapshot republish is gated on this so + // repeated same-state hook pings don't fan a rebuild out to every client. + return ( + !previous || + previous.payload.state !== payload.state || + previous.payload.workingMode !== payload.workingMode || + previous.payload.prompt !== payload.prompt || + (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || + (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || + (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) || + (previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) || + (previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null) + ) + } + + private clearAgentRowSnapshotsForPty(ptyId: string): void { + for (const [paneKey, snapshot] of this.latestAgentStatusByPaneKey) { + if (snapshot.ptyId === ptyId) { + this.latestAgentStatusByPaneKey.delete(paneKey) + } + } + } + + getPtyOutputSequence(ptyId: string): number { + return this.ptyOutputSequenceById.get(ptyId) ?? 0 + } + + private recordAgentPromptLifecycleState(ptyId: string, status: AgentStatus | null): void { + if (status === 'permission') { + this.recordAgentPromptPermissionObservation(ptyId) + } + const current = this.agentPromptLifecycleByPtyId.get(ptyId) + const updatedAt = Date.now() + if (!current) { + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: status === 'working' ? 1 : 0, + updatedAt + }) + return + } + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: + current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + updatedAt + }) + } + + private recordAgentPromptPermissionObservation(ptyId: string): void { + this.agentPromptPermissionSequenceByPtyId.set( + ptyId, + (this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0) + 1 + ) + } + + private restoreAgentPromptLifecycleByteOrder( + ptyId: string, + titleInput: string, + lastPayloadTitleOffset: number | null + ): void { + if (lastPayloadTitleOffset === null) { + return + } + const titleRange = findLastCompleteOscTitleRange(titleInput) + if (!titleRange || titleRange.end <= lastPayloadTitleOffset) { + return + } + const title = extractLastOscTitle(titleInput) + if (title === null) { + return + } + const status = detectAgentStatusFromTitle(title) + const current = this.agentPromptLifecycleByPtyId.get(ptyId) + if (!current || current.status === status) { + return + } + this.agentPromptLifecycleByPtyId.set(ptyId, { + status, + workingSequence: + current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + updatedAt: Date.now() + }) + } + + private getPtyLifecycleGeneration(ptyId: string): number { + const existing = this.ptyLifecycleGenerationById.get(ptyId) + if (existing !== undefined) { + return existing + } + const generation = this.nextPtyLifecycleGeneration++ + this.ptyLifecycleGenerationById.set(ptyId, generation) + return generation + } + + private advancePtyLifecycleGeneration(ptyId: string): void { + this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) + // Why: a stop whose exit never arrived would otherwise stay armed across a + // same-id respawn and label the NEXT process's crash an operator close — + // the exact lie this cause model exists to remove. + this.stopRequestedPtyIds.delete(ptyId) + this.agentPromptLifecycleByPtyId.delete(ptyId) + this.agentPromptPermissionSequenceByPtyId.delete(ptyId) + this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) + this.legacyWorkerRecoveredPtys.delete(ptyId) + // Why: a respawn under the same session id needs its own subscriber-driven attach. + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) + this.spawnPublishedPtys.delete(ptyId) + // Why: a provider response belongs to the process generation that issued + // it; a respawn must neither reuse its frame nor join its in-flight call. + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) + } + + synchronizePtyOutputSequenceFromProvider( + ptyId: string, + providerSequence: { value: number; generation: 'continued' | 'reset' }, + runtimeSequenceAtSpawnStart = 0 + ): number { + if ( + !Number.isFinite(providerSequence.value) || + providerSequence.value < 0 || + !Number.isFinite(runtimeSequenceAtSpawnStart) || + runtimeSequenceAtSpawnStart < 0 + ) { + return this.getPtyOutputSequence(ptyId) + } + const baseline = Math.floor(providerSequence.value) + const currentSequence = this.getPtyOutputSequence(ptyId) + const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart)) + const postSpawnSequence = currentSequence - sequenceAtSpawnStart + const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId) + const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0 + const providerOffset = + providerSequence.generation === 'reset' + ? sequenceAtSpawnStart + : (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0) + const providerBaseline = providerOffset + baseline + + if (providerSequence.generation === 'reset') { + this.advancePtyLifecycleGeneration(ptyId) + // Why: daemon respawn/cold restore starts a new absolute domain. Old + // emulator state cannot remain authoritative over the replacement. + if (replacesExistingRuntimeGeneration) { + this.disposeHeadlessTerminal(ptyId) + } + this.providerModeTrackersByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.wslDistro = null + } + // Why: raced post-spawn bytes may already contain the replacement's permission state. + if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { + this.resetTrackedTerminalStateForProviderGeneration(ptyId) + } + } + + const synchronizedSequence = + providerSequence.generation === 'reset' + ? currentSequence + : wasInitialized + ? currentSequence + : providerBaseline + postSpawnSequence + this.ptyOutputSequenceById.set(ptyId, synchronizedSequence) + this.providerSequenceInitializedPtys.add(ptyId) + this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset) + + const snapshotMayCoverMissingState = + (providerSequence.generation === 'continued' && !wasInitialized) || + (postSpawnSequence > 0 && + providerSequence.generation === 'reset' && + replacesExistingRuntimeGeneration) || + (providerSequence.generation === 'continued' && + wasInitialized && + providerBaseline > currentSequence) + if (snapshotMayCoverMissingState) { + // Why: bytes can cross the control/stream sockets around attach. Until a + // full renderer/provider snapshot is available, a partial model is unsafe. + this.providerSnapshotPreferredPtys.add(ptyId) + } else if (providerSequence.generation === 'reset') { + this.providerSnapshotPreferredPtys.delete(ptyId) + } + + const headless = this.headlessTerminals.get(ptyId) + if (headless && !wasInitialized && providerSequence.generation === 'continued') { + // Why: daemon bytes can reach main just before spawn resolves. Queue the + // baseline behind those writes so their emulator sequence is rebased too. + headless.writeChain = headless.writeChain.then(() => { + headless.outputSequence = synchronizedSequence + }) + } + return synchronizedSequence + } + + subscribeToTerminalData( + ptyId: string, + listener: (data: string, meta?: RuntimeTerminalDataMeta) => void + ): () => void { + return addListenerToMap(this.dataListeners, ptyId, listener) + } + + setRemoteTerminalSourceRangeConsumerHooks( + hooks: RemoteTerminalSourceRangeConsumerHooks | null + ): void { + this.remoteTerminalSourceRangeConsumerHooks = hooks + } + + attachRemoteTerminalSourceRangeConsumer( + identity: RemoteTerminalSourceRangeStreamIdentity + ): boolean { + return this.remoteTerminalSourceRangeConsumerHooks?.attach(identity) ?? false + } + + settleRemoteTerminalSourceRanges( + identity: RemoteTerminalSourceRangeStreamIdentity, + ranges: readonly TerminalOutputSourceRange[] + ): void { + this.remoteTerminalSourceRangeConsumerHooks?.settle(identity, ranges) + } + + reserveRemoteTerminalSourceRangeReplacement( + identity: RemoteTerminalSourceRangeStreamIdentity, + requiredSeq: number, + reason: string + ): RemoteTerminalSourceRangeReplacementReservation | null { + return ( + this.remoteTerminalSourceRangeConsumerHooks?.reserveReplacement( + identity, + requiredSeq, + reason + ) ?? null + ) + } + + commitRemoteTerminalSourceRangeReplacement( + reservation: RemoteTerminalSourceRangeReplacementReservation, + publication: RemoteTerminalSourceRangeReplacementPublication + ): boolean { + return ( + this.remoteTerminalSourceRangeConsumerHooks?.commitReplacement(reservation, publication) ?? + false + ) + } + + rollbackRemoteTerminalSourceRangeReplacement( + reservation: RemoteTerminalSourceRangeReplacementReservation, + reason: string + ): boolean { + return ( + this.remoteTerminalSourceRangeConsumerHooks?.rollbackReplacement(reservation, reason) ?? false + ) + } + + cancelRemoteTerminalSourceRanges( + identity: RemoteTerminalSourceRangeStreamIdentity, + ranges: readonly TerminalOutputSourceRange[], + reason: string + ): void { + this.remoteTerminalSourceRangeConsumerHooks?.cancel(identity, ranges, reason) + } + + /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so + * the daemon background mark (keep-tail stream thinning) can resync — a + * live mobile/web view consumes raw bytes and must never be thinned, even + * while the desktop pane is hidden. */ + onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null + + private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { + try { + this.onRemoteTerminalViewPresenceChanged?.(ptyId) + } catch (err) { + console.error('[runtime] remote view presence listener threw', { ptyId, err }) + } + } + + /** Registered by terminal-RPC subscribe/multiplex streams: while a remote + * view subscriber is attached its xterm answers queries with view + * authority and the model responder must stay silent. Returns an + * idempotent release. */ + registerRemoteTerminalViewSubscriber(ptyId: string): () => void { + this.remoteTerminalViewSubscriberCounts.set( + ptyId, + (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.ensureSubscriberDrivenProviderAttach(ptyId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.remoteTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + /** A local daemon session main knows is live but has never ingested a byte + * from — i.e. no pane ever attached it, so the daemon is not emitting. + * Headless state exists only after the first ingested byte; a snapshot + * reconcile in flight implies a spawn-path attach already happened. */ + private isKnownUnattachedLocalDaemonPty(ptyId: string): boolean { + if (this.headlessTerminals.has(ptyId) || this.providerSnapshotPreferredPtys.has(ptyId)) { + return false + } + // A spawn published (or admission pending) this generation already + // attaches the provider stream; a replacement under a reused id must not + // read as the discovered never-attached session it replaced. + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { + return false + } + // SSH panes have their own lease/reattach machinery. + if (parseAppSshPtyId(ptyId)) { + return false + } + const pty = this.ptysById.get(ptyId) + return pty !== undefined && pty.connectionId === null && pty.connected + } + + /** First remote view subscriber of a never-attached local daemon session: + * have main attach so output starts flowing. Attach-only (never spawns), + * no resize, no renderer mount/focus — works headless. Releases never + * detach: continued ingestion is the point, and daemon detach is stubbed. */ + private ensureSubscriberDrivenProviderAttach(ptyId: string): void { + const controller = this.ptyController + if ( + !controller?.attach || + this.subscriberDrivenProviderAttachesByPtyId.has(ptyId) || + !this.isKnownUnattachedLocalDaemonPty(ptyId) + ) { + return + } + const attach = controller.attach + // Async wrapper: a synchronous controller throw must not break subscribe. + const attempt = (async () => attach(ptyId))().catch(() => false) + this.subscriberDrivenProviderAttachesByPtyId.set(ptyId, attempt) + void attempt.then((attached) => { + // Why: an unprovable session must not be pinned as attached; a later + // subscriber may retry once the daemon can prove it. + if (!attached && this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === attempt) { + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + } + }) + } + + private reconcileSubscriberDrivenProviderAttach(ptyId: string): void { + if (!this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + const pending = this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) + if (!pending) { + this.ensureSubscriberDrivenProviderAttach(ptyId) + return + } + if (this.subscriberDrivenProviderAttachInventoryWaiters.has(ptyId)) { + return + } + this.subscriberDrivenProviderAttachInventoryWaiters.add(ptyId) + void pending.then((attached) => { + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) + if (attached || !this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + if (this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === pending) { + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + } + this.ensureSubscriberDrivenProviderAttach(ptyId) + }) + } + + /** Mark a raw-output viewer without transferring terminal query authority. */ + registerRawTerminalViewSubscriber(ptyId: string): () => void { + this.rawTerminalViewSubscriberCounts.set( + ptyId, + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.rawTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.rawTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + /** Raw stream presence prevents provider thinning without changing reply ownership. */ + hasRawTerminalViewSubscriber(ptyId: string): boolean { + return ( + (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0 || + this.hasRemoteTerminalViewSubscriber(ptyId) + ) + } + + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { + if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { + return true + } + return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 + } + + isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { + // Why: a passive phone watching desktop-sized output must not race the + // desktop xterm. Mobile becomes reply authority only with the mobile floor. + if (this.getDriver(ptyId).kind !== 'mobile') { + return false + } + const subscribers = this.mobileSubscribers.get(ptyId) + if (!subscribers) { + return false + } + // Why: soft-leave resubscribe preserves the original subscription time but + // reinserts the record. Elect fitted responders from that stable age, not + // mutable Map order or passive desktop-mode watchers. + let earliest: { clientId: string; subscribedAt: number } | null = null + for (const subscriber of subscribers.values()) { + if (!subscriber.wasResizedToPhone) { + continue + } + if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { + earliest = subscriber + } + } + return earliest?.clientId === clientId + } + + subscribeToFitOverrideChanges( + ptyId: string, + listener: (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): () => void { + return addListenerToMap(this.fitOverrideListeners, ptyId, listener) + } + + subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void { + return addListenerToMap(this.driverListeners, ptyId, listener) + } + + private notifyFitOverrideListeners( + ptyId: string, + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', + cols: number, + rows: number + ): void { + const listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + return + } + notifyRuntimeListeners(listeners, (listener) => listener({ mode, cols, rows }), 'fit-override') + } + + serializeTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + return this.serializeTerminalBufferFromAvailableState(ptyId, opts) + } + + async serializeAuthoritativeTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts, { + timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + retireOnTimeout: true + }) + if (providerSnapshot) { + return providerSnapshot + } + return this.serializeTerminalBufferFromAvailableState(ptyId, opts) + } + + /** Raw keystroke pass-through for the pop-out dashboard's terminal preview. + * Honors the mobile-presence lock like the main window's pty:write path. */ + async writeTerminalPreviewInput(ptyId: string, data: string): Promise { + if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { + return false + } + try { + await assertTerminalInputWithinLimitWithYield(data) + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) + await this.writeTerminalInputChunks( + ptyId, + data, + { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') + } + } + }, + admitted + ) + return true + } catch { + return false + } + } + + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + + serializeMainTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string | null + lastTitle?: string + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + scrollbackAnsi?: string + terminalOwner?: 'shell' + } | null> { + return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) + } + + async serializeHiddenOutputRecoveryBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + terminalOwner?: 'shell' + } | null> { + const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { + ...opts, + includeEmpty: true + }) + if (headlessSnapshot) { + return headlessSnapshot + } + // Why: hidden-output recovery is initiated by the desktop renderer. If the + // runtime has not built headless state yet, the mounted xterm is still the + // best available state and avoids a false "snapshot unavailable" result. + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) + } + + async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { + const leaf = this.resolveLeafForHandle(handle) + if (!leaf?.ptyId) { + throw new Error('terminal_not_found') + } + // Why: clear is a terminal UI action (Cmd+K on desktop), not shell input. + // Route through the controller so renderer-owned xterm buffers, daemon + // sessions, and SSH relay sessions all drop scrollback before the next + // mobile snapshot. + await this.ptyController?.clearBuffer?.(leaf.ptyId) + await this.clearHeadlessTerminalBuffer(leaf.ptyId) + return { handle, cleared: true } + } + + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { + return this.ptyController?.getSize?.(ptyId) ?? null + } + + // Why: a width reflow on a normal-buffer PTY must re-stream the full + // scrollback to mobile so it rewraps at the new cols, but alternate-screen + // TUIs (vim, Claude Code) own their repaint and have no scrollback — for + // those the mobile client just resizes xterm geometry and consumes the + // TUI's own redraw, so the resize re-stream must be skipped. Provider state + // covers restored PTYs whose main-side emulator is only a partial suffix. + isTerminalAlternateScreen(ptyId: string): boolean { + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false + } + return ( + this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? + this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? + false + ) + } + + // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch + // start with a fresh headless emulator that has zero scrollback, even though + // the daemon's on-disk checkpoint and the desktop xterm both contain the + // full prior history. Without this hydration, mobile subscribers see only + // the bare current prompt because serializeHeadlessTerminalBuffer always + // wins over the renderer-path fallback. Seeding the emulator with the + // adapter's snapshot/cold-restore data makes mobile and desktop agree on + // what scrollback is available. + seedHeadlessTerminal( + ptyId: string, + data: string, + size?: { cols: number; rows: number }, + metadata: HeadlessSeedMetadata = {} + ): void { + if (!data) { + return + } + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + // Why: emulator already has live data — re-seeding would duplicate + // every byte. The seed is only valid when the emulator is fresh. + if (metadata.preferProviderIfExisting) { + this.providerSnapshotPreferredPtys.add(ptyId) + } + return + } + const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) + this.headlessTerminals.set(ptyId, state) + this.recordOsc7MetadataForPty(ptyId, data) + this.recordRecentPtyOutputForPathProvenance(ptyId, data) + state.writeChain = state.writeChain + .then(async () => { + // Why: seed writes never set forwardQueryReplies — the main-side + // replay guard. A snapshot containing old queries must answer no one. + await state.emulator.write(data) + // Why AFTER the seed write: the snapshot payload cannot carry kitty + // pushes (rehydrateSequences deliberately omits them), but ordering + // behind it keeps the parse deterministic. Unflagged like the seed — + // re-applying flags must answer no one. + if (typeof metadata.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) + } + if (metadata.cwd !== undefined) { + state.emulator.setCwd(metadata.cwd) + } + if (metadata.oscLinks !== undefined) { + state.emulator.setRestoredOscLinks(metadata.oscLinks) + } + // Why derived from the emulator: the seed bytes bypass ownership.scan, + // so the scanner must inherit the restored alternate-screen state or a + // pane seeded mid-TUI never arms its recovery trigger. + state.ownership.seedOwner(metadata.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) + this.providerSnapshotPreferredPtys.delete(ptyId) + }) + .catch(() => { + // Seeding is best-effort; live data will continue to populate the + // emulator even if the snapshot replay fails. + }) + } + + // Why: reattach/cold-restore/replay payloads arrive as spawn RPC results and + // never pass through onPtyData, so after a relaunch the records backing + // `terminal list`/`terminal read` stayed blank while the session was alive. + // Seed semantics (applySeededAgentStatus precedent): write state only — no + // waiters, no orchestration events, and no lastOutputAt, because restored + // bytes are historical output, not fresh activity. + seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void { + const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null + if (seed) { + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + // Why: live bytes outrank the seed — only never-written records take it, + // so a same-run remount reattach cannot re-apply history it already has. + if (pty && restoredTerminalTailSeedAllowed(pty)) { + applyRestoredTerminalTailSeed(pty, seed) + this.primeWaitBlockedBaselineFromSeededTail(ptyId) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (restoredTerminalTailSeedAllowed(leaf)) { + applyRestoredTerminalTailSeed(leaf, seed) + } + } + } + if (restore.lastTitle) { + // Why: mirror renderer hydration — a title main already tracked live outranks the payload's persisted one. + this.applySeededAgentStatus(ptyId, this.getTrackedRawTitleForPty(ptyId) ?? restore.lastTitle) + } + } + + // Why: hydrate the runtime headless emulator from the desktop renderer's + // xterm buffer on the first onPtyData byte after a PTY is taken over by a + // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals + // is populated synchronously so concurrent live writes from + // trackHeadlessTerminalData chain after the seed via the same writeChain. + // See docs/mobile-prefer-renderer-scrollback.md. + private maybeHydrateHeadlessFromRenderer(ptyId: string): void { + if (this.headlessHydrationState.has(ptyId)) { + return + } + const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId) + if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) { + // Daemon-snapshot seed already populated the emulator — skip hydration. + this.headlessHydrationState.set(ptyId, 'done') + return + } + const controller = this.ptyController + if (!controller?.serializeBuffer || !controller.hasRendererSerializer) { + return + } + if (!controller.hasRendererSerializer(ptyId)) { + // Renderer hasn't registered yet (or never will). Live writes lazy- + // create the state via trackHeadlessTerminalData on this same tick. + return + } + + if (providerSnapshotPreferred) { + // Why: a stream byte can create a partial model before restored history + // arrives. A mounted renderer snapshot can safely replace that model. + this.disposeHeadlessTerminal(ptyId) + } + + this.headlessHydrationState.set(ptyId, 'pending') + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + // Why: hydration writes below never set forwardQueryReplies (main-side + // replay guard) — renderer-buffer snapshots can embed stale queries. + const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) + this.headlessTerminals.set(ptyId, state) + + // Why: append the seed work to writeChain so live writes queued by + // trackHeadlessTerminalData (after this method returns synchronously) + // execute AFTER the seed-write resolves. If we awaited inline before + // setting headlessTerminals, the live byte would lazy-create a separate + // state and the seed-resolve would overwrite it, dropping live bytes. + state.writeChain = state.writeChain.then(async () => { + try { + const rendered = await controller.serializeBuffer!(ptyId, { + scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + altScreenForcesZeroRows: true + }) + if (!rendered || rendered.data.length === 0) { + return + } + this.recordOsc7MetadataForPty(ptyId, rendered.data) + this.recordRecentPtyOutputForPathProvenance(ptyId, rendered.data) + // Resize to renderer's dims so the seed reflows correctly into the + // emulator's grid, then resize back to PTY dims (if known) so live + // writes use the correct cell layout. + if (rendered.cols !== dims.cols || rendered.rows !== dims.rows) { + state.emulator.resize(rendered.cols, rendered.rows) + } + await state.emulator.write(rendered.data) + const ptyDims = this.getTerminalSize(ptyId) + if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { + state.emulator.resize(ptyDims.cols, ptyDims.rows) + } + // Why: the renderer xterm no longer sees synthetic hook title frames + // (they feed main's tracker only), so its serializer lastTitle can be + // stale here. Prefer main's tracked title; the renderer's is only the + // seed when main has observed none (fresh relaunch, cold tracker). + state.ownership.seedOwner(undefined, { + alternateScreen: state.emulator.isAlternateScreen + }) + const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle + if (seedTitle) { + state.emulator.setLastTitle(seedTitle) + this.applySeededAgentStatus(ptyId, seedTitle) + } + this.providerSnapshotPreferredPtys.delete(ptyId) + } catch { + // Hydration is best-effort. Live writes continue via the same + // writeChain that this catch-arm leaves intact. + } finally { + this.headlessHydrationState.set(ptyId, 'done') + } + }) + } + + // Why: seed-derived agent status reflects historical state. Orchestration + // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react + // to LIVE transitions, so this helper writes leaf.lastAgentStatus only, + // leaves lastAgentStatusObservedLive untouched, and never resolves waiters. + // detectAgentStatusFromTitle wrap mirrors the live path so seeded and live + // values are the same union member, keeping downstream `=== 'idle'` checks + // correct. + private applySeededAgentStatus(ptyId: string, title: string): void { + if (!title) { + return + } + // Why: a relaunched main starts its per-PTY title tracker cold — without + // this seed it misses the parked working→idle completion and never arms + // the stale-title timer for a persisted 'working' title. Seeding no-ops + // once a live title was observed, so live state always wins. + this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) + const status = detectAgentStatusFromTitle(title) + // Why: live observations store normalized titles, so seeds must match — + // otherwise the first live frame after hydration compares unequal and + // touches session tabs once for no visible change. + const seededTitle = normalizeTerminalTitle(title) + const pty = this.ptysById.get(ptyId) + if (pty) { + const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitle = seededTitle + pty.lastOscTitleAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = seededTitle + leaf.lastOscTitleAt = this.nextTitleObservationSequence() + if (status !== null) { + leaf.lastAgentStatus = status + } + } + } + + /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at + * ingestion only — never re-read at reply time. */ + private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { + return shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: this.store?.getSettings(), + hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) + }) + } + + private trackHeadlessTerminalData( + ptyId: string, + data: string, + outputSequence: number, + forwardQueryReplies = false + ): Promise { + const state = this.getOrCreateHeadlessTerminal(ptyId) + const completion = state.writeChain.then(async () => { + // Why: the ingestion-time ownership decision is closed over this + // chain link; async scheduling cannot retroactively change it. + // Why inside the chain: the ownership mirror must observe live bytes in + // the same total order as seeds (seedOwner also runs on this chain). + state.ownership.scan(data) + await state.emulator.write(data, { forwardQueryReplies }) + state.outputSequence = outputSequence + }) + // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. + state.writeChain = completion.catch(() => {}) + return completion + } + + /** Shared factory for the per-PTY runtime emulators (seed, hydration, and + * lazy live-byte creation): wires the Phase-5 query-reply sink and the + * ConPTY DA1 override. The daemon emulator never goes through here. */ + private createPtyHeadlessTerminalState( + ptyId: string, + dims: { cols: number; rows: number } + ): RuntimeHeadlessTerminal { + let state: RuntimeHeadlessTerminal | null = null + const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) + const emulator = new HeadlessEmulator({ + cols: dims.cols, + rows: dims.rows, + pathFlavor, + remotePosixFileUriAuthority: + !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + wslDistro: this.ptysById.get(ptyId)?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), + // Why: replies take the provider input path (same entry as pty:write — + // daemon shell-ready gating and the SSH relay write apply unchanged), + // NOT writePtyInput, so renderer interactive-output metering never + // counts responder traffic as user-input echo. + onQueryReply: (reply) => { + // Why the identity check: queued writeChain links can parse after + // disposeHeadlessTerminal, and daemon respawns reuse session ids — a + // stale link's reply must never reach a successor PTY under this id. + if (state !== null && this.headlessTerminals.get(ptyId) === state) { + if ( + !shouldForwardHeadlessTerminalQueryReply(this.ptysById.get(ptyId)?.launchAgent, reply) + ) { + return + } + // Why this write is safe pre-shell-ready: daemon Session.write + // QUEUES (never drops) input while the POSIX shell-ready gate is + // pending and flushes at the ready marker or the 15s + // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query + // reply is delayed at most that bound, not lost. + this.ptyController?.write(ptyId, reply) + } + } + }) + if (isNativeWindowsConptyPty(ptyId)) { + emulator.installConptyPrimaryDeviceAttributesOverride() + } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } + const constructed: RuntimeHeadlessTerminal = { + emulator, + outputSequence: 0, + writeChain: Promise.resolve(), + ownership: new PtyShellOwnershipMirror(async () => { + const controller = this.ptyController + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if ( + !controller?.confirmShellForeground || + this.headlessTerminals.get(ptyId) !== constructed + ) { + return false + } + const confirmed = await controller.confirmShellForeground(ptyId) + return ( + confirmed && + this.headlessTerminals.get(ptyId) === constructed && + this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration + ) + }) + } + state = constructed + return state + } + + /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via + * markNativeWindowsConptyPty when the spawn mark lands after daemon stream + * data already created this PTY's emulator. Idempotent emulator-side. */ + private ensureNativeWindowsConptyDa1Override(ptyId: string): void { + if (isNativeWindowsConptyPty(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() + } + } + + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + return existing + } + const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state = this.createPtyHeadlessTerminalState(ptyId, size) + this.headlessTerminals.set(ptyId, state) + return state + } + + private replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void { + this.disposeHeadlessTerminal(ptyId) + this.providerSnapshotPreferredPtys.add(ptyId) + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) + this.headlessTerminals.set(ptyId, state) + state.writeChain = state.writeChain + .then(async () => { + const snapshot = await this.serializeProviderTerminalBuffer(ptyId) + if (!snapshot) { + return + } + const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + // Why: a newer live OSC 7 can arrive while the snapshot is in flight; + // only seed metadata while no post-correction CWD has won the race. + if (!this.terminalCwdByPtyId.has(ptyId)) { + this.recordOsc7MetadataForPty(ptyId, data) + } + await state.emulator.write(data) + if (snapshot.cwd !== undefined) { + state.emulator.setCwd(snapshot.cwd) + if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) { + this.terminalCwdByPtyId.set(ptyId, snapshot.cwd) + } + } + if (snapshot.oscLinks !== undefined) { + state.emulator.setRestoredOscLinks(snapshot.oscLinks) + } + state.ownership.seedOwner(snapshot.terminalOwner, { + alternateScreen: state.emulator.isAlternateScreen + }) + state.outputSequence = snapshot.seq + }) + .catch(() => { + // Best-effort: live bytes already chain behind this replacement state. + }) + .finally(() => { + this.providerSnapshotPreferredPtys.delete(ptyId) + }) + } + + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: terminal reflow is a parser operation. It must sit in the same + // per-PTY stream as output bytes or restore snapshots can bake in wraps + // from the wrong terminal width. + state.writeChain = state.writeChain + .then(() => { + state.emulator.resize(cols, rows) + }) + .catch(() => { + // Best-effort mirror tracking; live PTY streaming must continue even + // if xterm rejects a raced resize during teardown. + }) + } + + // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile + // mirror or a resubscribing mobile client resurrects the cleared scrollback. + async clearHeadlessTerminalBuffer(ptyId: string): Promise { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: headless writes are queued to preserve xterm parser order. Clear + // must join that same chain or an earlier PTY chunk can finish after the + // clear request and repopulate mobile scrollback. + state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) + await state.writeChain + } + + private async serializeTerminalBufferFromAvailableState( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + terminalOwner?: 'shell' + } | null> { + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + // Why: pre-attach stream bytes only form a suffix of restored state. A + // sequenced provider snapshot safely reconciles live bytes; renderer is + // the fallback when an older provider cannot expose that boundary. + const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) + if (providerSnapshot) { + return providerSnapshot + } + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + if (rendererSnapshot) { + return rendererSnapshot + } + } + const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) + if (headlessSnapshot) { + return headlessSnapshot + } + + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + if (!rendererSnapshot) { + return this.serializeProviderTerminalBuffer(ptyId, opts) + } + if (rendererSnapshot.data.length > 0) { + return rendererSnapshot + } + // Why: parked desktop panes register serializers before their xterm has + // hydrated. Treat that empty shell as provisional so retained provider + // history can restore mobile without forcing the desktop pane to mount. + const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) + return providerSnapshot && + (providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi)) + ? providerSnapshot + : rendererSnapshot + } + + async serializeRendererTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string | null + lastTitle?: string + source?: 'renderer' + oscLinks?: TerminalOscLinkRange[] + kittyKeyboardFlags?: number + } | null> { + if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { + return null + } + let rendererSnapshot: { + data: string + cols: number + rows: number + seq?: number + cwd?: string | null + lastTitle?: string + oscLinks?: TerminalOscLinkRange[] + kittyKeyboardFlags?: number + } | null = null + try { + // Why: recovery/read fallback wants visible alt-screen content (e.g. an + // active TUI), so altScreenForcesZeroRows is FALSE here. Hydration is + // the only path that suppresses alt-screen scrollback. + rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { + scrollbackRows: opts.scrollbackRows, + altScreenForcesZeroRows: false + }) ?? Promise.resolve(null)) + } catch { + // Why: terminal snapshots should not depend on a mounted renderer pane. + // If renderer serialization races reload/unmount, callers can still use + // their existing null fallback paths. + } + return rendererSnapshot + ? this.preferTrackedLastTitle(ptyId, { + ...rendererSnapshot, + cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), + source: 'renderer' as const + }) + : null + } + + private async serializeProviderTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {}, + wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {} + ): Promise { + const generation = this.getPtyLifecycleGeneration(ptyId) + const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0)) + let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId) + // Why before the re-acquire branch: an unresponsive provider is a property of + // the process, not of the row count one caller asked for. Checking retirement + // only after re-acquiring let a wider request replace the retired entry and + // hang again; the hung call's own settle still clears it and allows recovery. + if (acquisition?.generation === generation && acquisition.timedOut) { + return null + } + if ( + !acquisition || + acquisition.generation !== generation || + acquisition.scrollbackRows < scrollbackRows + ) { + const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation) + acquisition = { generation, scrollbackRows, promise, timedOut: false } + this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition) + void promise.finally(() => { + if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) { + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + } + }) + } + if (acquisition.timedOut) { + return null + } + if (typeof wait.timeoutMs !== 'number') { + return acquisition.promise + } + const result = await withTimeout< + { settled: true; value: PtyProviderBufferSnapshot | null } | { settled: false } + >( + acquisition.promise.then((value) => ({ settled: true as const, value })), + wait.timeoutMs, + { settled: false as const } + ) + if (!result.settled) { + if (wait.retireOnTimeout) { + acquisition.timedOut = true + } + return null + } + return result.value + } + + private async captureProviderTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number }, + generation: number + ): Promise { + const liveModeTracker = new TerminalKittyKeyboardModeTracker() + let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) + if (!liveModeTrackers) { + liveModeTrackers = new Set() + this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers) + } + liveModeTrackers.add(liveModeTracker) + try { + // Why: daemon PTYs survive an app relaunch before any renderer mounts. + // Mobile still needs their retained history without navigating desktop. + const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + return null + } + const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() + if (typeof snapshot.alternateScreen === 'boolean') { + snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } else { + // Why: older providers omit mode metadata, but their ANSI snapshot + // still carries the DECSET/DECRST needed to classify the active screen. + snapshotModeTracker.scanReplay(snapshot.data) + } + const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch + let effectiveAlternateScreen: boolean | undefined + if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) { + const modeTracker = new TerminalKittyKeyboardModeTracker() + if (observedSnapshotMode) { + modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } + // Why: stream bytes received after the request began can be newer + // than snapshot metadata, so an observed live transition wins. + if (liveModeTracker.hasObservedAlternateScreenSwitch) { + modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } + this.providerModeTrackersByPtyId.set(ptyId, modeTracker) + effectiveAlternateScreen = modeTracker.isAlternateScreen + } + const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 + const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, { + ...snapshot, + seq: providerOffset + snapshot.seq, + ...(effectiveAlternateScreen !== undefined + ? { alternateScreen: effectiveAlternateScreen } + : {}) + }) + if (liveModeTracker.hasObservedAlternateScreenSwitch) { + this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot) + } + return reconciledSnapshot + } catch { + return null + } finally { + liveModeTrackers.delete(liveModeTracker) + if (liveModeTrackers.size === 0) { + this.providerModeSnapshotScansByPtyId.delete(ptyId) + } + } + } + + private async withVisibleSnapshotFallback( + ptyId: string, + read: RuntimeTerminalRead, + opts: { cursor?: number; limit?: number } = {}, + providerSnapshot: ProviderSnapshotReadOptions = {} + ): Promise { + if (typeof opts.cursor === 'number') { + return read + } + const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts) + const recoveredWorkerFallback = + read.tail.length === 0 && this.legacyWorkerRecoveredPtys.has(ptyId) + // Why: a live daemon session no pane ever attached has ingested zero bytes, + // so only the provider holds its screen. Unprovable state stays empty. + const neverAttachedProviderFallback = + read.tail.length === 0 && + !recoveredWorkerFallback && + this.isKnownUnattachedLocalDaemonPty(ptyId) + if (recoveredWorkerFallback || neverAttachedProviderFallback) { + const providerProjection = await this.readProviderTerminalTailLines( + ptyId, + opts.limit, + providerSnapshot + ) + if (providerProjection.lines.length > 0) { + return buildVisibleSnapshotReadFallback( + read, + providerProjection.lines, + opts.limit, + providerProjection.draft + ) + } + } + const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) + const providerModeUnknown = + this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) + if ( + !blankFallback && + !recoveredWorkerFallback && + !providerModeUnknown && + !knownAlternateScreen && + !this.headlessTerminals.has(ptyId) + ) { + return read + } + const visibleState = await this.readVisibleTerminalState(ptyId) + if ( + !blankFallback && + !recoveredWorkerFallback && + !knownAlternateScreen && + !visibleState?.isAlternateScreen + ) { + return read + } + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) + } + if (projection.lines.length === 0) { + return read + } + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) + } + + private async readProviderTerminalTailLines( + ptyId: string, + limit: number | undefined, + snapshotOptions: ProviderSnapshotReadOptions = {} + ): Promise { + const generation = this.getPtyLifecycleGeneration(ptyId) + const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) + const snapshot = await this.serializeProviderTerminalBuffer( + ptyId, + { scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit }, + snapshotOptions + ) + if (!snapshot) { + return { lines: [] } + } + // Why: a cached acquisition can carry scrollback this caller did not ask for, + // so visible-only reads parse the grid itself rather than trusting the request. + if (snapshotOptions.visibleScreenOnly) { + const projection = await this.parseVisibleSnapshot(snapshot) + // Live bytes ordered after the provider frame make that frame stale. + return this.getPtyLifecycleGeneration(ptyId) === generation && + this.getPtyOutputSequence(ptyId) <= snapshot.seq + ? projection + : { lines: [] } + } + const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + if (data.length === 0) { + return { lines: [] } + } + const emulator = new HeadlessEmulator({ + cols: snapshot.cols, + rows: snapshot.rows, + scrollback: lineLimit + }) + try { + await emulator.write(data) + const projection = projectTerminalTailLines(emulator, lineLimit) + return this.getPtyLifecycleGeneration(ptyId) === generation && + this.getPtyOutputSequence(ptyId) <= snapshot.seq + ? projection + : { lines: [] } + } finally { + emulator.dispose() + } + } + + private async visibleSnapshotPreview(ptyId: string, preview: string): Promise { + const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) + const providerModeUnknown = + this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) + if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) { + return preview + } + const visibleState = await this.readVisibleTerminalState(ptyId) + if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { + return preview + } + let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } + if (projection.lines.length === 0) { + projection = await this.readRendererVisibleSnapshotLines(ptyId) + } + return projection.lines.length > 0 ? buildPreview(projection.lines, '') : preview + } + + private async readVisibleTerminalState( + ptyId: string + ): Promise { + if (!this.providerSnapshotPreferredPtys.has(ptyId)) { + return this.readHeadlessVisibleTerminalState(ptyId) + } + + const generation = this.getPtyLifecycleGeneration(ptyId) + const outputSequence = this.getPtyOutputSequence(ptyId) + const cached = this.providerVisibleStateByPtyId.get(ptyId) + const trackedMode = this.providerModeTrackersByPtyId.get(ptyId) + if ( + cached?.generation === generation && + outputSequence <= cached.sequence && + (!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen) + ) { + return cached + } + if (trackedMode && !trackedMode.isAlternateScreen) { + const headlessState = await this.readHeadlessVisibleTerminalState(ptyId) + return headlessState + ? { ...headlessState, isAlternateScreen: false } + : { + lines: [], + isAlternateScreen: false, + sequence: outputSequence, + generation + } + } + if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) { + return null + } + + const snapshot = await this.serializeProviderTerminalBuffer( + ptyId, + { scrollbackRows: 0 }, + { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } + ) + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) + return null + } + this.providerVisibleRetryAtByPtyId.delete(ptyId) + if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) { + // Why: the provider frame can predate a mode switch observed while its + // RPC was pending; the ordered live emulator owns the post-switch grid. + const liveState = await this.readHeadlessVisibleTerminalState(ptyId) + if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) { + return liveState + } + } + const projection = await this.parseVisibleSnapshot(snapshot) + if ( + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyOutputSequence(ptyId) > snapshot.seq + ) { + return null + } + const visibleState: RuntimeVisibleTerminalState = { + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), + isAlternateScreen: snapshot.alternateScreen ?? false, + sequence: snapshot.seq, + generation + } + this.providerVisibleStateByPtyId.set(ptyId, visibleState) + return visibleState + } + + private async readHeadlessVisibleTerminalState( + ptyId: string + ): Promise { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + const generation = this.getPtyLifecycleGeneration(ptyId) + await state.writeChain + if ( + this.headlessTerminals.get(ptyId) !== state || + this.getPtyLifecycleGeneration(ptyId) !== generation + ) { + return null + } + const projection = projectVisibleTerminalLines(state.emulator) + return { + lines: projection.lines, + ...(projection.draft ? { draft: projection.draft } : {}), + isAlternateScreen: state.emulator.isAlternateScreen, + sequence: state.outputSequence, + generation + } + } + + private async parseVisibleSnapshot(snapshot: { + data: string + cols: number + rows: number + }): Promise<{ lines: string[]; draft?: string }> { + if (snapshot.data.length === 0) { + return { lines: [] } + } + const emulator = new HeadlessEmulator({ + cols: snapshot.cols, + rows: snapshot.rows, + scrollback: 0 + }) + try { + await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + return projectVisibleTerminalLines(emulator) + } finally { + emulator.dispose() + } + } + + private async readRendererVisibleSnapshotLines( + ptyId: string + ): Promise { + const controller = this.ptyController + if (!controller?.serializeBuffer) { + return { lines: [] } + } + if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { + return { lines: [] } + } + try { + // Why: raw PTY tails can be whitespace-only while a full-screen TUI is + // visibly nonblank in renderer xterm. Ask the renderer for the active + // screen instead of reusing the headless transcript path. + const snapshot = await withTimeout( + controller.serializeBuffer(ptyId, { + scrollbackRows: 0, + altScreenForcesZeroRows: false + }), + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + null + ) + if (!snapshot || snapshot.data.length === 0) { + return { lines: [] } + } + return this.parseVisibleSnapshot(snapshot) + } catch { + return { lines: [] } + } + } + + private async serializeHeadlessTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} + ): Promise<{ + data: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + source?: 'headless' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + scrollbackAnsi?: string + kittyKeyboardFlags?: number + terminalOwner?: 'shell' + // Why: dangling mid-escape tail the restorer must write LAST, after any + // reset, so the next live chunk completes it instead of rendering it + // literally (Bug E / #7329). + pendingEscapeTailAnsi?: string + } | null> { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + await state.writeChain + await state.ownership.settle() + // Why: normal history is separated from an active alternate frame, so the + // caller's scrollback policy can be honored without painting it into alt. + const scrollbackRows = opts.scrollbackRows ?? 0 + const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const terminalOwner = state.ownership.owner + const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi + return data.length > 0 || opts.includeEmpty === true + ? this.preferTrackedLastTitle(ptyId, { + data, + frameRestoreAnsi: snapshot.frameRestoreAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), + lastTitle: snapshot.lastTitle, + seq: state.outputSequence, + source: 'headless' as const, + oscLinks: snapshot.oscLinks, + scrollbackAnsi: snapshot.scrollbackAnsi, + // Why beside outputSequence and never re-read later: the flags must + // describe the same stream position as the image, or replay would + // apply push/pop transitions twice or out of order. + ...(parseTerminalKittyKeyboardFlags(snapshot.modes?.kittyKeyboardFlags) !== undefined + ? { kittyKeyboardFlags: snapshot.modes.kittyKeyboardFlags } + : {}), + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}), + ...(terminalOwner ? { terminalOwner } : {}), + // Why: lets the renderer skip the destructive scrollback clear when + // restoring an alt-screen snapshot — clearing wipes xterm's own + // history that the TUI relies on for scroll-up after a tab return. + alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen, + // Why NOT folded into data: the renderer writes its post-replay + // reset after data, and any ESC after a dangling partial aborts it. + // The restorer writes this last (Bug E fix). + pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi + }) + : null + } + + private disposeHeadlessTerminal(ptyId: string): void { + this.headlessHydrationState.delete(ptyId) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + this.headlessTerminals.delete(ptyId) + // Why: queued chain links still parse below before the emulator disposes; + // sever the reply sink now so they cannot write to a respawned PTY that + // reused this id (belt to the sink's state-identity check). + state.emulator.disableQueryReplyForwarding() + state.ownership.dispose() + state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) + } + + resolveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + return { ptyId: leaf.ptyId } + } + + // Why: remote clients hold handles across transport reconnects. A handle + // minted for a concrete PTY must never silently adopt a different PTY that + // later occupies the same pane — that misroutes keystrokes (#7718). Handles + // still awaiting their first PTY (ptyId null) may adopt it, which preserves + // the mobile pre-spawn subscribe flow. + resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + if ( + record.ptyId !== null && + (leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) + ) { + throw new Error('terminal_handle_stale') + } + return { ptyId: leaf.ptyId } + } + + getOrchestrationCompatibilityHostId(): 'local' { + return 'local' + } + + registerOrchestrationCompatibilitySshAttachment( + targetId: string, + connectionIncarnation: string + ): OrchestrationCompatibilitySshAttachmentAuthority { + const authority = Object.freeze({ + kind: 'ssh' as const, + targetId, + connectionIncarnation, + attachmentId: randomUUID() + }) + this.orchestrationCompatibilitySshAttachments.set(authority.attachmentId, authority) + return authority + } + + releaseOrchestrationCompatibilitySshAttachment(attachmentId: string): void { + this.orchestrationCompatibilitySshAttachments.delete(attachmentId) + } + + verifyOrchestrationCompatibilityCaller( + evidence: OrchestrationCompatibilityEvidence | null | undefined, + options?: { currentRuntimeLaunchSufficient?: boolean } + ): OrchestrationCompatibilityCallerAuthority | null { + const terminalHandle = + typeof evidence?.terminalHandle === 'string' ? evidence.terminalHandle.trim() : '' + const claimedPaneKey = typeof evidence?.paneKey === 'string' ? evidence.paneKey.trim() : '' + const launchToken = typeof evidence?.launchToken === 'string' ? evidence.launchToken.trim() : '' + const host = evidence?.host + if (!terminalHandle || !claimedPaneKey || !launchToken) { + return null + } + const terminal = this.getOrchestrationDispatchAuthority(terminalHandle) + if ( + !terminal?.processIncarnation || + !terminal.paneKey || + !this.orchestrationCompatibilityHostMatches(terminal.hostScope, host) + ) { + return null + } + const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') + let terminalProvenance: 'current_runtime' | 'restored' + if (terminal.launchTokenHash) { + if (launchTokenHash !== terminal.launchTokenHash) { + return null + } + terminalProvenance = 'current_runtime' + } else { + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(terminal.ptyId) + if ( + !receipt || + receipt.ptyId !== terminal.ptyId || + receipt.worktreeId !== terminal.worktreeId || + receipt.terminalHandle !== terminal.terminalHandle || + receipt.paneKey !== terminal.paneKey || + receipt.processIncarnation !== terminal.processIncarnation || + !this.orchestrationCompatibilityHostScopesEqual(receipt.hostScope, terminal.hostScope) + ) { + return null + } + terminalProvenance = 'restored' + } + if ( + options?.currentRuntimeLaunchSufficient && + terminalProvenance === 'current_runtime' && + claimedPaneKey === terminal.paneKey + ) { + // Why: the checks above bind a fresh launch to its live PTY, host, and + // launch secret. Only an exact live-pane match may skip hook attestation. + return this.freezeOrchestrationCompatibilityCallerAuthority( + terminal, + terminal.processIncarnation, + claimedPaneKey, + terminalHandle, + launchTokenHash + ) + } + const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ + paneKey: claimedPaneKey, + launchTokenHash, + connectionId: terminal.hostScope.kind === 'ssh' ? terminal.hostScope.targetId : null, + terminalProvenance + }) + if (!attestation || attestation.paneKey !== terminal.paneKey) { + return null + } + return this.freezeOrchestrationCompatibilityCallerAuthority( + terminal, + terminal.processIncarnation, + attestation.paneKey, + terminalHandle, + launchTokenHash + ) + } + + private freezeOrchestrationCompatibilityCallerAuthority( + terminal: OrchestrationCompatibilityTerminalAuthority, + processIncarnation: string, + paneKey: string, + terminalHandle: string, + launchTokenHash: string + ): OrchestrationCompatibilityCallerAuthority { + return Object.freeze({ + hostScope: Object.freeze({ ...terminal.hostScope }), + paneKey, + terminalHandle, + processIncarnation, + launchTokenHash + }) + } + + private orchestrationCompatibilityHostMatches( + hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'], + host: OrchestrationCompatibilityHostStamp | undefined + ): boolean { + if (hostScope.kind === 'local') { + return host === undefined + } + if (hostScope.kind === 'wsl') { + return ( + host?.kind === 'wsl' && host.hostId === hostScope.hostId && host.distro === hostScope.distro + ) + } + if (host?.kind !== 'ssh' || host.targetId !== hostScope.targetId) { + return false + } + const authority = this.orchestrationCompatibilitySshAttachments.get(host.attachmentId) + return ( + authority?.targetId === host.targetId && + authority.connectionIncarnation === host.connectionIncarnation + ) + } + + private orchestrationCompatibilityHostScopesEqual( + left: OrchestrationCompatibilityTerminalAuthority['hostScope'], + right: OrchestrationCompatibilityTerminalAuthority['hostScope'] + ): boolean { + if (left.kind !== right.kind) { + return false + } + if (left.kind === 'local' && right.kind === 'local') { + return left.hostId === right.hostId + } + if (left.kind === 'wsl' && right.kind === 'wsl') { + return left.hostId === right.hostId && left.distro === right.distro + } + return left.kind === 'ssh' && right.kind === 'ssh' && left.targetId === right.targetId + } + + private getOrchestrationCompatibilityHostScope( + pty: RuntimePtyWorktreeRecord + ): OrchestrationCompatibilityTerminalAuthority['hostScope'] | null { + if (pty.connectionId) { + return { kind: 'ssh', targetId: pty.connectionId } + } + if (pty.isWsl || pty.wslDistro) { + return pty.wslDistro ? { kind: 'wsl', hostId: 'local', distro: pty.wslDistro } : null + } + return { kind: 'local', hostId: 'local' } + } + + private rememberRestoredOrchestrationAuthority( + pty: RuntimePtyWorktreeRecord, + terminalHandle: string, + incarnationId: string + ): void { + const paneKey = pty.paneKey + const hostScope = this.getOrchestrationCompatibilityHostScope(pty) + if (!paneKey || !parsePaneKey(paneKey) || !hostScope) { + this.restoredOrchestrationAuthorityByPtyId.delete(pty.ptyId) + return + } + this.restoredOrchestrationAuthorityByPtyId.set( + pty.ptyId, + Object.freeze({ + ptyId: pty.ptyId, + worktreeId: pty.worktreeId, + terminalHandle, + paneKey, + processIncarnation: `${pty.ptyId}:${incarnationId}`, + hostScope: Object.freeze({ ...hostScope }) + }) + ) + } + + getOrchestrationDispatchAuthority( + terminalHandle: string + ): OrchestrationCompatibilityTerminalAuthority | null { + let ptyId: string | null + try { + ptyId = + this.getLivePtyForHandle(terminalHandle)?.pty.ptyId ?? + this.resolveLiveLeafForHandle(terminalHandle)?.ptyId ?? + null + } catch { + return null + } + if (!ptyId) { + return null + } + const pty = this.ptysById.get(ptyId) + if (!pty?.connected) { + return null + } + const hostScope = this.getOrchestrationCompatibilityHostScope(pty) + if (!hostScope) { + return null + } + return { + runtimeId: this.runtimeId, + terminalHandle, + ptyId, + worktreeId: pty.worktreeId, + processIncarnation: this.getTerminalProcessIncarnation(terminalHandle), + paneKey: pty.paneKey, + launchTokenHash: pty.launchToken + ? createHash('sha256').update(pty.launchToken).digest('hex') + : null, + hostScope + } + } + + /** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary + * restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose + * spawn-time `ptyPaneKey` mapping teardown could not resolve. */ + private collectPaneKeysForPty(ptyId: string): Set { + const paneKeys = new Set() + const pty = this.ptysById.get(ptyId) + if (pty?.paneKey && parsePaneKey(pty.paneKey)) { + paneKeys.add(pty.paneKey) + } + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) + if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { + paneKeys.add(receipt.paneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { + paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) + } + } + return paneKeys + } + + private retirePtyAgentLaunchAuthority(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + return + } + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) + if (!pty.launchToken && !receipt && !pty.launchAgent) { + return + } + const paneKeys = this.collectPaneKeysForPty(ptyId) + this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) + pty.launchToken = null + pty.launchIncarnationId = null + pty.launchAgent = null + for (const paneKey of paneKeys) { + this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) + } + } + + async resolveTerminalCwd(handle: string): Promise { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + if (!ptyId) { + return null + } + const tracked = this.terminalCwdByPtyId.get(ptyId) + if (tracked) { + return tracked + } + try { + const cwd = await this.ptyController?.getCwd?.(ptyId) + return cwd && cwd.trim().length > 0 ? cwd : null + } catch { + return null + } + } + + resolveTerminalFileUriHostname(handle: string): string | null { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + return ptyId ? (this.terminalFileUriHostnameByPtyId.get(ptyId) ?? null) : null + } + + private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { + let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) + if (!recentOutputBuffer) { + // Boundaries are only owed to the one-time activation backfill; once + // tracking is live, new buffers keep the read-collapsing hot path. + recentOutputBuffer = new RecentPtyOutputBuffer({ + preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive + }) + this.recentPtyOutputById.set(ptyId, recentOutputBuffer) + } + recentOutputBuffer.append(data) + if ( + this.recentPtyPathCandidateTrackingActive || + // Why: an over-window chunk is stored pre-sliced, so activation backfill + // could never replay its original text. Extract while intact; oversized + // chunks are rare, so the desktop-only gate still skips the hot path. + data.length > RECENT_PTY_OUTPUT_LIMIT + ) { + this.recentPtyPathCandidatesById.set( + ptyId, + appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) + ) + } + } + + activateRecentPtyPathCandidateTracking(): void { + if (this.recentPtyPathCandidateTrackingActive) { + return + } + this.recentPtyPathCandidateTrackingActive = true + // Why: synchronous backfill from the retained raw windows so a file tap + // right after first mobile connect resolves exactly as before the gate. + // Replay each retained chunk in its original full form: joining or + // trimming chunks would change the candidate set (e.g. a window cut can + // shorten an over-4KiB line under the extractor's line guard, minting + // candidates the eager extractor rejected). + // Accepted best-effort loss: output that scrolled past the raw window + // before the first-ever connect no longer yields candidates. + for (const [ptyId, buffer] of this.recentPtyOutputById) { + let candidates = this.recentPtyPathCandidatesById.get(ptyId) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + for (let index = 0; index < chunks.length; index += 1) { + if (index === 0 && headChunkIsPartial) { + // A pre-sliced over-window chunk was already extracted eagerly at + // append time (while its original text was intact); replaying its + // truncated remainder would mint or drop candidates spuriously. + continue + } + candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) + } + if (candidates) { + this.recentPtyPathCandidatesById.set(ptyId, candidates) + } + // Chunk boundaries were owed only to this one-time backfill; return + // the buffer to the compact read-collapsing steady state. + buffer.compact() + } + } + + resolveTerminalContext( + handle: string + ): { worktreeId: string; connectionId: string | null } | null { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + const pty = ptyId ? this.ptysById.get(ptyId) : null + return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null + } + + // Why: remote clients cannot resolve this runtime's WSL project preference, + // so host-affecting RPCs (skill discovery) resolve it from the owning store. + resolveProjectRuntimeForWorktree( + worktreeId: string | null | undefined + ): ProjectExecutionRuntimeResolution | undefined { + return this.store && worktreeId + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), worktreeId) + : undefined + } + + getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { + let pty: RuntimePtyWorktreeRecord | null = null + try { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null + } catch { + return 'orca' + } + if (!pty) { + return 'orca' + } + return resolveTerminalOrchestrationCliCommand({ + connectionId: pty.connectionId, + isWsl: pty.isWsl, + worktreeId: pty.worktreeId, + projectRuntime: this.store + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) + : undefined + }) + } + + hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { + // Why: safety net for any query path that never saw a mobile onReady — + // lazily backfill so the answer matches pre-gate behavior. + if (!this.recentPtyPathCandidateTrackingActive) { + this.activateRecentPtyPathCandidateTracking() + } + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null + if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { + return true + } + const candidates = ptyId ? this.recentPtyPathCandidatesById.get(ptyId) : null + return candidates + ? recentTerminalPathCandidatesIncludePath(candidates, pathText, absolutePath) + : false + } + + registerSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void | Promise, + connectionId?: string + ): void { + // Why: mobile clients reconnect frequently (phone lock, network switch). + // The RPC client re-sends terminal.subscribe on reconnect, creating a new + // handler before the old one is cleaned up. Without this, the old data + // listener leaks in dataListeners and duplicates every PTY data event. + const existing = this.subscriptionCleanups.get(subscriptionId) + if (existing) { + // Why: the stable id is about to belong to a newer connection; detach + // the old owner before its asynchronous cleanup can overlap the rebind. + this.removeSubscriptionConnectionIndex(subscriptionId) + // Why: evict by the owner we captured, never by the key — a keyed evict + // would resolve to whoever holds the id at call time. + this.cleanupOwnedSubscription(subscriptionId, existing) + } + this.subscriptionCleanups.set(subscriptionId, cleanup) + if (connectionId) { + let set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + set = new Set() + this.subscriptionsByConnection.set(connectionId, set) + } + set.add(subscriptionId) + this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) + } + } + + // Why: teardown keyed only by a string tears down whoever owns that key *now*. + // A mobile reconnect rebinds the stable `${terminal}:${clientId}` id, so a late + // teardown from the dead connection would kill the replacement stream (STA-4510). + // Callers that own a registration must go through this handle instead. + registerOwnedSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void | Promise, + connectionId?: string + ): SubscriptionRegistration { + this.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) + return { + releaseIfCurrent: () => this.cleanupOwnedSubscription(subscriptionId, cleanup) + } + } + + cleanupSubscription(subscriptionId: string): void { + void this.cleanupSubscriptionAndWait(subscriptionId).catch((error) => { + console.error(`[runtime] subscription cleanup failed for ${subscriptionId}:`, error) + }) + } + + // Why: a client-supplied unsubscribe names a stable id it may no longer own — a + // reconnect or make-before-break migration rebinds that id to a newer connection, + // and honoring the stale message would kill the live stream (STA-4510). + // Returns whether the subscription was actually torn down. + cleanupSubscriptionIfOwnedByConnection( + subscriptionId: string, + connectionId: string | undefined + ): boolean { + // Why: an absent connectionId means a connection-less caller — the local + // unix-socket dispatch path, gated by the 0o600 metadata token. That tier keeps + // unconditional teardown authority; this guard scopes socket clients only. + if (!connectionId) { + this.cleanupSubscription(subscriptionId) + return true + } + // Why: an id with no registration is already gone, not a refusal. Reporting + // false there would tell a retrying client to keep chasing a dead id. + if (!this.subscriptionCleanups.has(subscriptionId)) { + return true + } + if (this.subscriptionConnectionByEntry.get(subscriptionId) !== connectionId) { + return false + } + this.cleanupSubscription(subscriptionId) + return true + } + + private cleanupOwnedSubscription( + subscriptionId: string, + expectedCleanup: () => void | Promise + ): void { + // Why: the ownership check is synchronous and cleanupSubscriptionAndWait re-reads + // the map before its first await, so nothing can rebind in between. Delegating + // preserves the in-flight join, the retain-on-failure, and the retry contract. + if (this.subscriptionCleanups.get(subscriptionId) !== expectedCleanup) { + return + } + this.cleanupSubscription(subscriptionId) + } + + retrySubscriptionCleanupAfter( + subscriptionId: string, + cleanupOwner: () => void | Promise, + gate: Promise + ): void { + const failedGeneration = this.subscriptionCleanupPromises.get(subscriptionId) + void gate.then( + async () => { + await (failedGeneration?.cleanup === cleanupOwner + ? failedGeneration.promise.catch(() => undefined) + : undefined) + while (this.subscriptionCleanups.get(subscriptionId) === cleanupOwner) { + const newerGeneration = this.subscriptionCleanupPromises.get(subscriptionId) + if (newerGeneration?.cleanup === cleanupOwner) { + // Why: a caller may already be retrying this owner; wait for that + // exact generation so a rejected join cannot consume our retry. + await newerGeneration.promise.catch(() => undefined) + continue + } + this.cleanupSubscription(subscriptionId) + return + } + }, + () => undefined + ) + } + + async cleanupSubscriptionAndWait(subscriptionId: string): Promise { + const cleanup = this.subscriptionCleanups.get(subscriptionId) + if (!cleanup) { + return + } + const inFlight = this.subscriptionCleanupPromises.get(subscriptionId) + if (inFlight?.cleanup === cleanup) { + return inFlight.promise + } + let cleanupResult: void | Promise + try { + cleanupResult = cleanup() + } catch (error) { + cleanupResult = Promise.reject(error) + } + const promise = Promise.resolve(cleanupResult) + .then(() => { + // Why: a reconnect can replace this id while old async cleanup runs; + // only the generation that registered this callback may remove it. + if (this.subscriptionCleanups.get(subscriptionId) !== cleanup) { + return + } + this.subscriptionCleanups.delete(subscriptionId) + this.removeSubscriptionConnectionIndex(subscriptionId) + }) + .finally(() => { + if (this.subscriptionCleanupPromises.get(subscriptionId)?.promise === promise) { + this.subscriptionCleanupPromises.delete(subscriptionId) + } + }) + this.subscriptionCleanupPromises.set(subscriptionId, { cleanup, promise }) + return promise + } + + private removeSubscriptionConnectionIndex(subscriptionId: string): void { + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + } + } + + cleanupSubscriptionsByPrefix(prefix: string): void { + const ids = Array.from(this.subscriptionCleanups.keys()).filter((id) => id.startsWith(prefix)) + for (const id of ids) { + this.cleanupSubscription(id) + } + } + + // Why: invoked from the WebSocket transport's on-close hook so streaming + // listeners registered for this exact socket get torn down even when other + // sockets sharing the same deviceToken are still alive (multi-screen + // mobile). Without this sweep, listeners leak across every reconnect. + cleanupSubscriptionsForConnection(connectionId: string): void { + const set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + return + } + // Why: snapshot the ids before iterating because cleanupSubscription + // mutates both the set and the index map. + const ids = Array.from(set) + for (const id of ids) { + if (this.subscriptionConnectionByEntry.get(id) !== connectionId) { + set.delete(id) + continue + } + this.cleanupSubscription(id) + } + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. + // Each subscriber gets its own listener. Returns an unsubscribe function + // that the subscription cleanup mechanism calls on disconnect. + onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { + this.notificationListeners.add(listener) + return () => { + this.notificationListeners.delete(listener) + } + } + + getMobileNotificationListenerCount(): number { + return this.notificationListeners.size + } + + // Why: bounded replay buffer for the mobile reconnect catch-up (#8129). + // Every dispatched notification is recorded with a monotonic seq so a + // reconnecting client can fetch exactly the events it missed. Kept on the + // service instance (not per-client) because the buffer is a global, + // idempotent-by-seq source of truth; clients watermark their own position. + private readonly mobileNotificationReplay = new MobileNotificationReplayBuffer() + + dispatchMobileNotification(event: MobileNotificationEvent): void { + const seq = this.mobileNotificationReplay.record(event) + // Why: surface the desktop-assigned seq to live listeners so they can watermark the last event + // delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes). + notifyRuntimeListeners( + this.notificationListeners, + (listener) => + listener({ + ...event, + notificationSeq: seq, + notificationEpoch: this.mobileNotificationReplay.epoch + }), + 'mobile-notification' + ) + } + + // Returns notifications dispatched after lastSeenSeq. Idempotent: the same + // watermark always yields the same set, so a client cannot be re-pushed an + // already-delivered event (the adversarial-review gate for #8129). + getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] { + return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch) + } + + // Why (#8591): the seq counter is per-process and restarts at 0 on every desktop + // launch, but the client's watermark is persisted. Clients need the epoch to tell + // a stale watermark from a valid one — see MobileNotificationReplayBuffer. + getMobileNotificationEpoch(): string { + return this.mobileNotificationReplay.epoch + } + + dismissMobileNotification(notificationId: string): void { + this.dispatchMobileNotification({ type: 'dismiss', notificationId }) + } + + /** Plugin panel action notifications.show. Native on desktop, relayed to + * paired mobile clients either way (mirrors notifications:dispatch). */ + async dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> { + // Why: prefix with the plugin id so a plugin cannot spoof an Orca system + // notification or impersonate another plugin. + const title = `${input.pluginId}: ${input.title}` + const body = input.body ?? '' + let delivered = false + try { + delivered = getRuntimeDesktopSurface().showNotification({ title, body }) + } catch { + // A host with no notification display still relays to paired clients below. + } + this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) + return { delivered } + } + + // ─── Account Services (mobile RPC bridge) ───────────────────── + + setAccountServices(services: RuntimeAccountServices): void { + this.accountServices = services + } + + setCommitMessageAgentEnvironmentResolvers( + resolvers: CommitMessageAgentEnvironmentResolvers + ): void { + this.commitMessageAgentEnv = resolvers + } + + getCommitMessageAgentEnvironmentResolvers(): CommitMessageAgentEnvironmentResolvers | undefined { + return this.commitMessageAgentEnv ?? undefined + } + + // Lists the speech-model catalog joined with live download/ready state, plus + // the current enabled flag + selected model, so mobile can present a dictation + // setup sheet and drive remote enable/download. Always targets this (paired) + // desktop — speech never routes to a worktree's SSH host. + async listMobileSpeechModels(): Promise { + if (!this.store) { + throw new Error('voice_dictation_unavailable') + } + const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() + const states = await getSpeechModelManager(this.store).getModelStates() + const stateById = new Map(states.map((state) => [state.id, state])) + const models: RuntimeSpeechModelSummary[] = SPEECH_MODEL_CATALOG.map((manifest) => { + const state = stateById.get(manifest.id) + return { + id: manifest.id, + label: manifest.label, + provider: manifest.provider === 'openai' ? 'openai' : 'local', + sizeBytes: manifest.sizeBytes ?? null, + recommended: manifest.recommended === true, + status: state?.status ?? 'not-downloaded', + progress: state?.progress ?? null + } + }) + return { + enabled: voice.enabled === true, + selectedModelId: voice.sttModel ?? '', + dictationMode: voice.dictationMode === 'hold' ? 'hold' : 'toggle', + models + } + } + + // Fire-and-forget model download; the ModelManager writes progress into its + // per-model state, which mobile reads back via listMobileSpeechModels polling. + async downloadMobileSpeechModel(modelId: string): Promise<{ started: true }> { + if (!this.store) { + throw new Error('voice_dictation_unavailable') + } + const manifest = getCatalogModel(modelId) + if (!manifest || !isLocalSpeechModel(manifest)) { + throw new Error('voice_model_not_downloadable') + } + // Why: do not await — downloads run for tens of seconds; the call returns + // immediately and mobile polls for progress/ready. + void getSpeechModelManager(this.store) + .downloadModel(modelId) + .catch((err) => { + console.error('[runtime] mobile speech model download failed', { modelId, err }) + }) + return { started: true } + } + + async deleteMobileSpeechModel(modelId: string): Promise { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('voice_dictation_unavailable') + } + const store = this.store + try { + // The runtime store is adapted to the minimal speech settings contract used by deletion. + await deleteLocalSpeechModel({ + store: { + getSettings: () => store.getSettings(), + updateSettings: (updates, options) => store.updateSettings?.(updates, options) + }, + modelManager: getSpeechModelManager(store), + sttService: getSpeechSttService(store), + modelId + }) + } catch (error) { + throw new Error(getSpeechModelDeletionErrorCode(error) ?? 'voice_model_delete_failed') + } + return this.listMobileSpeechModels() + } + + // Enables/disables dictation and/or selects the model, merging into the + // existing voice settings so other voice fields are preserved. + async configureMobileDictation(params: { + enabled?: boolean + modelId?: string + dictationMode?: 'toggle' | 'hold' + }): Promise { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('voice_dictation_unavailable') + } + const current = this.store.getSettings().voice ?? getDefaultVoiceSettings() + // An explicit '' clears the selected model (the OptionalString RPC schema + // maps '' → undefined, so this only matters for direct callers); any other + // non-empty modelId must be a known catalog entry. + if (params.modelId !== undefined && params.modelId !== '' && !getCatalogModel(params.modelId)) { + throw new Error('voice_model_unknown') + } + const nextVoice: VoiceSettings = { + ...current, + ...(params.enabled !== undefined ? { enabled: params.enabled } : {}), + ...(params.modelId !== undefined ? { sttModel: params.modelId } : {}), + ...(params.dictationMode !== undefined ? { dictationMode: params.dictationMode } : {}) + } + this.store.updateSettings({ voice: nextVoice }, { notifyListeners: true }) + return this.listMobileSpeechModels() + } + + async startMobileDictation(params: { + dictationId: string + modelId?: string + clientId?: string + connectionId?: string + }): Promise<{ + dictationId: string + modelId: string + }> { + if (!this.store) { + throw new Error('voice_dictation_unavailable') + } + + const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() + if (!voice.enabled) { + throw new Error('voice_dictation_disabled') + } + + const modelId = params.modelId || voice.sttModel + if (!modelId) { + throw new Error('voice_model_not_selected') + } + + const modelState = await getSpeechModelManager(this.store).getModelState(modelId) + if (modelState.status !== 'ready') { + throw new Error(`voice_model_not_ready:${modelState.status}`) + } + + if (!params.clientId) { + throw new Error('dictation_requires_mobile_client') + } + + if (this.mobileDictation) { + throw new Error('dictation_already_active') + } + + const owner = `mobile:${params.dictationId}` + this.mobileDictation = { + id: params.dictationId, + owner, + clientId: params.clientId, + connectionId: params.connectionId, + state: 'starting', + partialText: '', + finalTexts: [], + errors: [] + } + + try { + await getSpeechSttService(this.store).startDictation( + modelId, + (event) => { + const session = this.mobileDictation + if (!session || session.id !== params.dictationId) { + return + } + if (event.type === 'partial') { + session.partialText = event.text ?? '' + } else if (event.type === 'final') { + const text = event.text?.trim() + if (text) { + session.finalTexts.push(text) + session.partialText = '' + } + } else if (event.type === 'error') { + session.errors.push(event.error ?? 'Speech worker error') + } + }, + undefined, + owner + ) + if (this.mobileDictation?.id !== params.dictationId) { + throw new Error('dictation_canceled') + } + this.mobileDictation.state = 'active' + } catch (error) { + if (this.mobileDictation?.id === params.dictationId) { + this.mobileDictation = null + } + throw error + } + + return { dictationId: params.dictationId, modelId } + } + + feedMobileDictation(params: { + dictationId: string + audioBase64: string + sampleRate: number + clientId?: string + connectionId?: string + }): { + dictationId: string + } { + const session = this.mobileDictation + if (!session || session.id !== params.dictationId) { + throw new Error('dictation_stream_not_started') + } + if (!params.clientId || session.clientId !== params.clientId) { + throw new Error('dictation_owner_mismatch') + } + if (session.connectionId && session.connectionId !== params.connectionId) { + throw new Error('dictation_owner_mismatch') + } + if (session.state !== 'active') { + throw new Error('dictation_stream_closing') + } + if (session.errors.length > 0) { + throw new Error(session.errors[0]) + } + + const pcm = Buffer.from(params.audioBase64, 'base64') + const samples = new Float32Array(Math.floor(pcm.length / 2)) + for (let i = 0; i < samples.length; i += 1) { + samples[i] = pcm.readInt16LE(i * 2) / 32768 + } + getSpeechSttService(this.store!).feedAudio(samples, params.sampleRate, session.owner) + return { dictationId: params.dictationId } + } + + async finishMobileDictation(params: { + dictationId: string + clientId?: string + connectionId?: string + }): Promise<{ + dictationId: string + text: string + }> { + const session = this.mobileDictation + if (!session || session.id !== params.dictationId) { + throw new Error('dictation_stream_not_started') + } + if (!params.clientId || session.clientId !== params.clientId) { + throw new Error('dictation_owner_mismatch') + } + if (session.connectionId && session.connectionId !== params.connectionId) { + throw new Error('dictation_owner_mismatch') + } + session.state = 'closing' + try { + await getSpeechSttService(this.store!).stopDictation(session.owner) + if (session.errors.length > 0) { + throw new Error(session.errors[0]) + } + const text = [...session.finalTexts, session.partialText].join(' ').trim() + return { dictationId: params.dictationId, text } + } finally { + if (this.mobileDictation?.id === session.id) { + this.mobileDictation = null + } + } + } + + async cancelMobileDictation(params: { + dictationId: string + clientId?: string + connectionId?: string + }): Promise<{ dictationId: string }> { + const session = this.mobileDictation + if ( + session?.id === params.dictationId && + params.clientId && + session.clientId === params.clientId && + (!session.connectionId || session.connectionId === params.connectionId) + ) { + session.state = 'closing' + try { + await getSpeechSttService(this.store!).stopDictation(session.owner) + } finally { + if (this.mobileDictation?.id === session.id) { + this.mobileDictation = null + } + } + } + return { dictationId: params.dictationId } + } + + private cancelMobileDictationSession(session: NonNullable): void { + if (session.state === 'closing') { + return + } + session.state = 'closing' + void getSpeechSttService(this.store!) + .stopDictation(session.owner) + .finally(() => { + if (this.mobileDictation?.id === session.id) { + this.mobileDictation = null + } + }) + } + + cancelMobileDictationForConnection(connectionId: string): void { + const session = this.mobileDictation + if (!session || session.connectionId !== connectionId) { + return + } + this.cancelMobileDictationSession(session) + } + + private cancelMobileDictationForClient(clientId: string): void { + const session = this.mobileDictation + if (!session || session.clientId !== clientId) { + return + } + this.cancelMobileDictationSession(session) + } + + private requireAccountServices(): RuntimeAccountServices { + if (!this.accountServices) { + throw new Error('Account services are not configured on this runtime') + } + return this.accountServices + } + + getAccountsSnapshot(): AccountsSnapshot { + const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() + return { + claude: claudeAccounts.listAccounts(), + codex: codexAccounts.listAccounts(), + rateLimits: rateLimits.getState() + } + } + + // Why: RateLimitService polls only when the Electron window is visible AND + // focused, and the inactive-account caches fill lazily when the user opens + // the desktop AccountsPane. Mobile has neither trigger, so without this the + // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed + // because partial usage is still useful for the rest of the snapshot. + async refreshAccountsForMobile(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refresh(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + // Why: connection migration replays subscriptions; use the stale-aware lane + // so a reconnect cannot turn one mobile viewer into continuous forced fetches. + async refreshAccountsForMobileSubscriber(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refreshIfStale(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + selectClaudeAccount(accountId: string | null): Promise { + return this.requireAccountServices().claudeAccounts.selectAccount(accountId) + } + + selectCodexAccount(accountId: string | null): Promise { + return this.requireAccountServices().codexAccounts.selectAccount(accountId) + } + + selectCodexAccountForTarget( + accountId: string | null, + target: CodexAccountSelectionTarget + ): Promise { + return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target) + } + + async consumeCodexRateLimitResetCredit( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope + ): Promise { + const { claudeAccounts, codexAccounts } = this.requireAccountServices() + const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + // Why: Codex selection and usage were captured before its mutation queue + // advanced. Re-reading them here could pair scope A with queued selection B. + const snapshot = { + claude: claudeAccounts.listAccounts(), + codex: result.codex, + rateLimits: result.rateLimits + } + if ('status' in result) { + return { + status: result.status, + retryDisposition: result.retryDisposition, + reason: result.reason, + scope: result.scope, + snapshot + } + } + return { + outcome: result.outcome, + scope: result.scope, + snapshot + } + } + + removeClaudeAccount(accountId: string): Promise { + return this.requireAccountServices().claudeAccounts.removeAccount(accountId) + } + + // Why: register a managed Claude account from a CLAUDE_CONFIG_DIR the caller + // already logged into. Lets the `orca account add` CLI drive `claude login` in + // the user's terminal on a headless host, then capture the credentials here — + // the desktop GUI's interactive add flow is unreachable over a remote runtime. + addClaudeAccountFromConfigDir( + configDir: string, + options?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + previousLegacyCredentialsSha256?: string | null + } + ): Promise { + return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + } + + removeCodexAccount(accountId: string): Promise { + return this.requireAccountServices().codexAccounts.removeAccount(accountId) + } + + // Why: Codex counterpart of addClaudeAccountFromConfigDir — register a managed + // Codex account from a CODEX_HOME the caller already logged into, so headless + // hosts can add accounts via `orca account add --agent codex`. + addCodexAccountFromHome( + sourceHome: string, + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } + ): Promise { + return this.requireAccountServices().codexAccounts.addAccountFromHome(sourceHome, target) + } + + // Why: rate-limit polling fires every 5 minutes and on account switch. + // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever + // RateLimitService pushes new usage data, mirroring the existing + // `rateLimits:update` IPC channel desktop already uses. + onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { + const services = this.requireAccountServices() + return services.rateLimits.onStateChange((rateLimits) => { + listener({ + claude: services.claudeAccounts.listAccounts(), + codex: services.codexAccounts.listAccounts(), + rateLimits + }) + }) + } + + // ─── Mobile Fit Override Management ───────────────────────── + + // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this + // is a thin shim that computes a `PtyLayoutTarget` and routes through + // `enqueueLayout`. Keeps the same observable return shape so older mobile + // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. + async resizeForClient( + ptyId: string, + mode: 'mobile-fit' | 'restore', + clientId: string, + cols?: number, + rows?: number + ): Promise<{ + cols: number + rows: number + previousCols: number | null + previousRows: number | null + mode: 'mobile-fit' | 'desktop-fit' + }> { + if (mode === 'mobile-fit') { + if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { + throw new Error('invalid_dimensions') + } + const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport(cols, rows) + + const currentSize = this.getTerminalSize(ptyId) + const existing = this.terminalFitOverrides.get(ptyId) + // Capture baseline cols/rows for the return value (existing override's + // baseline wins over current size to preserve original desktop dims + // across multiple re-fits). + const previousCols = existing?.previousCols ?? currentSize?.cols ?? null + const previousRows = existing?.previousRows ?? currentSize?.rows ?? null + + // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so + // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 + // (per-subscriber baseline) never matches. Stash the pre-fit PTY size + // into lastRendererSizes so restore lands on step 2 (renderer geometry) + // instead of step 3 (current phone-fit dims = no-op restore). + if (currentSize && !existing) { + this.lastRendererSizes.set(ptyId, { + cols: currentSize.cols, + rows: currentSize.rows + }) + } + + this.freshSubscribeGuard.add(ptyId) + let result: ApplyLayoutResult + try { + result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + if (!result.ok) { + throw new Error('resize_failed') + } + + // Why: mobile-fit via resizeForClient is a deliberate mobile action; + // the actor takes the floor (updates lastActedAt; mode-flip case is + // already handled by enqueueLayout above). + await this.mobileTookFloor(ptyId, clientId) + + return { + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + mode: 'mobile-fit' + } + } + + // restore mode + const override = this.terminalFitOverrides.get(ptyId) + if (!override) { + throw new Error('no_active_override') + } + // Only the owning client can restore — prevents one phone from undoing + // another phone's active fit. + if (override.clientId !== clientId) { + throw new Error('not_override_owner') + } + + const restore = this.resolveDesktopRestoreTarget(ptyId) + const result = await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + if (!result.ok) { + throw new Error('resize_failed') + } + + // Why: legacy mobile clients on the resizeForClient path also need a + // fit-override-listener notification (the renderer-side terminalFitOverrideChanged + // is already emitted by applyLayout's mode-flip path). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) + + return { + cols: restore.cols, + rows: restore.rows, + previousCols: null, + previousRows: null, + mode: 'desktop-fit' + } + } + + getTerminalFitOverride(ptyId: string) { + return this.terminalFitOverrides.get(ptyId) ?? null + } + + getAllTerminalFitOverrides(): Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + > { + const result = new Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + >() + for (const [ptyId, override] of this.terminalFitOverrides) { + result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) + } + for (const [ptyId] of this.remoteDesktopOwners) { + if (result.has(ptyId)) { + continue + } + const size = this.getTerminalSize(ptyId) + if (size) { + result.set(ptyId, { mode: 'remote-desktop-fit', ...size }) + } + } + return result + } + + getAllTerminalDrivers(): Map { + return new Map(this.currentDriver) + } + + getAllBrowserDrivers(): Map { + return new Map(this.currentBrowserDriver) + } + + private getBrowserDriver(browserPageId: string): RuntimeBrowserDriverState { + return this.currentBrowserDriver.get(browserPageId) ?? { kind: 'idle' } + } + + private setBrowserDriver(browserPageId: string, next: RuntimeBrowserDriverState): void { + const prev = this.getBrowserDriver(browserPageId) + if (prev.kind === next.kind) { + if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { + return + } + if (prev.kind !== 'mobile' && next.kind !== 'mobile') { + return + } + } + if (next.kind === 'idle') { + this.currentBrowserDriver.delete(browserPageId) + } else { + this.currentBrowserDriver.set(browserPageId, next) + } + this.notifier?.browserDriverChanged?.(browserPageId, next) + } + + getBrowserRemoteViewerPages(): string[] { + return Array.from(this.browserRemoteViewerPages) + } + + /** Republishes from the live subscriber set, so every add and remove has one settling point. */ + private publishBrowserRemoteViewers(browserPageId: string): void { + const watched = (this.activeBrowserScreencastsByPage.get(browserPageId)?.size ?? 0) > 0 + if (this.browserRemoteViewerPages.has(browserPageId) === watched) { + return + } + if (watched) { + this.browserRemoteViewerPages.add(browserPageId) + } else { + this.browserRemoteViewerPages.delete(browserPageId) + } + this.notifier?.browserRemoteViewersChanged?.(browserPageId, watched) + } + + reclaimBrowserForDesktop(browserPageId: string): boolean { + this.setBrowserDriver(browserPageId, { kind: 'desktop' }) + for (const stream of this.activeBrowserScreencastsByPage.get(browserPageId) ?? []) { + // Why: take-back revokes the phone's control, not a co-viewing desktop/web client's stream. + if (stream.drivesAsMobile) { + stream.cancel(true) + } + } + return true + } + + onClientDisconnected(clientId: string): void { + this.revokeTerminalFileGrantsForClient(clientId) + this.cancelMobileDictationForClient(clientId) + + // (1) Cancel pending restore-debounce timers owned by this client. + for (const [ptyId, entry] of this.pendingRestoreTimers) { + if (entry.clientId === clientId) { + clearTimeout(entry.timer) + this.pendingRestoreTimers.delete(ptyId) + } + } + + // (2) Promote any soft-leave grace owned by this client into immediate + // finalization. Grace existed to absorb a quick re-subscribe; a real + // disconnect kills any chance of re-subscribe. + // + // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md + // sub-case 2). Today's pre-rewrite code only restored when + // `mode === 'auto' && wasResizedToPhone`; the new design restores + // whenever the layout is currently `phone`. This is an intentional + // behavior fix — `mode === 'phone'` with no subscribers is a degenerate + // state nothing in product depends on. + for (const [ptyId, soft] of this.pendingSoftLeavers) { + if (soft.clientId !== clientId) { + continue + } + clearTimeout(soft.timer) + this.pendingSoftLeavers.delete(ptyId) + + // Cancel any in-flight 300ms restore timer too — we'll handle it inline. + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + const cur = this.layouts.get(ptyId) + // Why: Indefinite hold (mobileAutoRestoreFitMs == null) keeps the PTY + // at phone dims after the phone disconnects; the desktop banner's + // Restore button is the explicit return path. See + // docs/mobile-fit-hold.md. + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } + // Use the soft-leaver's snapshot baseline as a hint, falling + // through to resolveDesktopRestoreTarget for missing values. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = soft.record.previousCols ?? fallback.cols + const rows = soft.record.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (3) Immediate restore for PTYs where this client was the last + // mobile subscriber. With multi-mobile, peer subscribers keep the + // floor; only when the inner map empties do we transition to desktop. + const ptysWithSurvivingPeers: string[] = [] + const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] + for (const [ptyId, inner] of this.mobileSubscribers) { + const subscriber = inner.get(clientId) + if (!subscriber) { + continue + } + // Snapshot baseline before deleting — needed once mobileSubscribers + // entry is gone for the resolveDesktopRestoreTarget chain. + const baseline = + subscriber.previousCols != null && subscriber.previousRows != null + ? { cols: subscriber.previousCols, rows: subscriber.previousRows } + : null + inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + if (inner.size > 0) { + ptysWithSurvivingPeers.push(ptyId) + } else { + this.mobileSubscribers.delete(ptyId) + ptysToRestore.push({ ptyId, baseline }) + } + } + for (const { ptyId, baseline } of ptysToRestore) { + const cur = this.layouts.get(ptyId) + // Why: Indefinite hold gate — see soft-leaver branch above. + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = baseline?.cols ?? fallback.cols + const rows = baseline?.rows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (4) Driver re-election where peers survived. If the disconnecting + // client was the active driver, the most-recent surviving actor takes + // the floor. + for (const ptyId of ptysWithSurvivingPeers) { + const driver = this.getDriver(ptyId) + if (driver.kind !== 'mobile' || driver.clientId !== clientId) { + continue + } + const inner = this.mobileSubscribers.get(ptyId) + const next = inner ? this.pickMostRecentActor(inner) : null + if (!next) { + continue + } + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + + const mode = this.getMobileDisplayMode(ptyId) + if (mode === 'desktop') { + continue + } + const nextSub = inner!.get(next.clientId) + const nextViewport = nextSub?.viewport + if (!nextViewport) { + continue + } + void this.enqueueLayout(ptyId, { + kind: 'phone', + cols: nextViewport.cols, + rows: nextViewport.rows, + ownerClientId: next.clientId + }) + } + + // (5) Legacy-callers fallback. Older mobile builds use resizeForClient + // directly and never populate mobileSubscribers. For those PTYs the + // override carries the owning clientId; restore the layout when the + // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes + // (which the legacy mobile-fit branch stashes the pre-fit size into). + for (const [ptyId, override] of this.terminalFitOverrides) { + if (override.clientId !== clientId) { + continue + } + if (this.mobileSubscribers.has(ptyId)) { + continue + } + const cur = this.layouts.get(ptyId) + if (cur?.kind !== 'phone') { + continue + } + // Why: Indefinite hold gate — see soft-leaver branch above. Legacy + // mobile clients (resizeForClient path) honor the same setting. + if (this.getAutoRestoreFitMs() == null) { + continue + } + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = override.previousCols ?? fallback.cols + const rows = override.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } + } + + onPtyExit( + ptyId: string, + exitCode: number, + exitIncarnationId?: PtyIncarnationId, + options?: { + hostExitConfirmed?: boolean + cause?: TerminalExitCause + /** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which + * also drives the SSH surface decision and the liveness verdict: node-pty reports real exits + * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ + providerExitObserved?: boolean + } + ): void { + const pty = this.ptysById.get(ptyId) + if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { + return + } + this.invalidatePtyLivenessSnapshot() + // Why intent first: a requested stop can still be delivered by the provider's + // own exit event, whose status looks exactly like a natural finish. + // + // Why it yields to stop_unverified: a stop nobody confirmed is not a + // completed close. The process may still be running against a revoked + // dispatch — an incident a coordinator must hear about, not a routine + // teardown to be filed away and left unescalated. + const observedCause = options?.cause ?? resolveUnreportedExitCause(exitCode) + const stopNeverConfirmed = + observedCause.kind === 'unknown' && observedCause.reason === 'stop_unverified' + const exitCause: TerminalExitCause = + this.stopRequestedPtyIds.has(ptyId) && !stopNeverConfirmed + ? OPERATOR_CLOSE_EXIT_CAUSE + : observedCause + this.stopRequestedPtyIds.delete(ptyId) + const preservesAbnormalSshSurface = + this.isSshOwnedPtyId(ptyId) && + pty?.connectionId != null && + exitCode < 0 && + options?.hostExitConfirmed !== true + // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority + // receipt a receipt-only pane's key comes from. + const exitPaneKeys = this.collectPaneKeysForPty(ptyId) + if (preservesAbnormalSshSurface) { + if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') { + this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON) + } + this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) + } else { + this.retirePtyAgentLaunchAuthority(ptyId) + } + // Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can + // have survived it), while a real exit can also report -1 — so neither the numeric code nor the + // SSH surface predicate is sufficient on its own. Decided separately from + // preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip. + const processDeathCertified = + exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true + if (processDeathCertified && exitPaneKeys.size > 0) { + this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys) + } + const incarnationId = + exitIncarnationId ?? + pty?.incarnationId ?? + `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` + this.advancePtyLifecycleGeneration(ptyId) + this.notifyPtyExitListeners(ptyId) + const exactSurfaceByKey = new Map< + string, + Pick + >() + for (const leaf of this.getLeavesForPty(ptyId)) { + exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { + worktreeId: leaf.worktreeId, + parentTabId: leaf.tabId, + leafId: leaf.leafId + }) + } + const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') + if (pty?.tabId && parsedPaneKey) { + exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { + worktreeId: pty.worktreeId, + parentTabId: pty.tabId, + leafId: parsedPaneKey.leafId + }) + } + const exactSurfaces = [...exactSurfaceByKey.values()] + const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) + const exitMatchesPendingRegistration = + this.pendingPtyRegistrationIncarnations.has(ptyId) && + (pendingIncarnation === null || + exitIncarnationId === null || + exitIncarnationId === undefined || + pendingIncarnation === exitIncarnationId) + if (exitMatchesPendingRegistration) { + // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. + this.earlyExitedPtyIncarnations.set( + ptyId, + exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null + ) + } + const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) + const preservesIntentionalHandlelessSurface = + this.intentionalHandlelessPtyStops.has(ptyId) && + (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) + advertisedUrlWatcher.unbindPty(ptyId) + agentSessionPtyWriteGate.unbindPty(ptyId) + // Clean up new mobile state for this PTY + this.mobileSubscribers.delete(ptyId) + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + this.rawTerminalViewSubscriberCounts.delete(ptyId) + this.mobileDisplayModes.delete(ptyId) + this.resizeListeners.delete(ptyId) + this.lastRendererSizes.delete(ptyId) + this.recentPtyOutputById.delete(ptyId) + this.setupCompletionTokenByPtyId.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) + this.recentPtyPathCandidatesById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) + this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + this.clearAgentRowSnapshotsForPty(ptyId) + // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, + // process died, renderer reload) must release its team + nested panes map. + // Previously only explicit closeTerminal evicted it, so natural exits leaked + // one team per never-reused teamId for the runtime's lifetime. + const exitedTeamLeaderHandle = this.handleByPtyId.get(ptyId) + if (exitedTeamLeaderHandle) { + this.claudeAgentTeams.removeTeamForLeaderHandle(exitedTeamLeaderHandle) + } + // Layout state machine: clear `layouts` and `layoutQueues`. Any + // already-queued applyLayout work for this ptyId will run, but every + // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and + // short-circuits with `pty-exited`. + this.layouts.delete(ptyId) + this.layoutQueues.delete(ptyId) + this.freshSubscribeGuard.delete(ptyId) + this.cancelPendingDriverMutations(ptyId) + // Why: a cold restore can respawn under the same session id within the + // delayed-Enter window; the armed Enter would inject \r into the + // replacement and stamp rows it never received. + this.retireOrchestrationMailboxDeliveryForPty(ptyId) + + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + // Why: clear driver state and notify the renderer so any lock banner on + // this dead pane unmounts. Without this, the pane shows a stuck banner + // until tab teardown, and `getDriver(deadPtyId)` would keep returning a + // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. + if (this.currentDriver.has(ptyId)) { + this.currentDriver.delete(ptyId) + this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) + } + this.remoteDesktopViewers.delete(ptyId) + this.remoteDesktopOwners.delete(ptyId) + this.remoteDesktopHostReclaimTargets.delete(ptyId) + this.remoteDesktopViewerRevisions.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + if (pty) { + pty.connected = false + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(pty.ptyId, false) + pty.disconnectedAt = Date.now() + pty.lastExitCode = exitCode + pty.lastExitCause = exitCause + if (exitCode >= 0 || options?.hostExitConfirmed === true) { + // A real wait status from the owning host is the death certificate; the + // synthetic -1 we emit on a failed/unroutable stop is not. + this.forgetPtyLivenessVerdict(ptyId) + } + // Why: the exited process's live frames say nothing about a replacement. + // A same-id respawn makes the leaf writable again before any new title, + // so leaving this true would let push delivery type into the new process + // on the dead one's idle. lastAgentStatus itself stays for `ps` display. + pty.lastAgentStatusObservedLive = false + this.resolvePtyExitWaiters(pty, ptyId) + this.pruneDisconnectedPtyTranscript(pty) + } + if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. + this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) + } else { + // Why: permanent process exit is absence, not a starting/sleeping tab. + // Retire before publishing so paired clients never persist a ghost. + this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + } + + const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] + for (const leaf of this.getLeavesForPty(ptyId)) { + this.detachedPreAllocatedLeaves.delete(ptyId) + leaf.connected = false + leaf.writable = false + leaf.lastExitCode = exitCode + leaf.lastExitCause = exitCause + leaf.lastAgentStatusObservedLive = false + this.resolveExitWaiters(leaf) + const leafHandle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (leafHandle) { + exitedSurfaces.push({ handle: leafHandle, paneKey: `${leaf.tabId}:${leaf.leafId}` }) + } + } + // Why: an explicit whole-tab close drops the leaf from the graph *before* + // this exit lands, so a leaf-only walk found nothing and left the dispatch + // reading 'dispatched' forever against a dead process. The PTY's own handle + // and pane key survive that teardown, so settle from them too (STA-4603). + const ptyHandle = this.handleByPtyId.get(ptyId) + if (ptyHandle && !exitedSurfaces.some((surface) => surface.handle === ptyHandle)) { + exitedSurfaces.push({ handle: ptyHandle, paneKey: pty?.paneKey ?? null }) + } + if (!preservesAbnormalSshSurface) { + for (const surface of exitedSurfaces) { + this.failActiveDispatchOnExit(surface.handle, surface.paneKey, exitCode, exitCause) + } + } + this.pruneDisconnectedPtyRecords() + } + + // ─── Driver state (mobile-presence lock) ────────────────────────── + // + // See docs/mobile-presence-lock.md. + + getDriver(ptyId: string): DriverState { + return this.currentDriver.get(ptyId) ?? { kind: 'idle' } + } + + private setDriver(ptyId: string, next: DriverState): void { + const prev = this.getDriver(ptyId) + if (prev.kind === next.kind) { + if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { + return + } + if (prev.kind !== 'mobile' && next.kind !== 'mobile') { + return + } + } + if (next.kind === 'idle') { + this.currentDriver.delete(ptyId) + } else { + this.currentDriver.set(ptyId, next) + } + this.notifier?.terminalDriverChanged(ptyId, next) + const listeners = this.driverListeners.get(ptyId) + if (listeners) { + notifyRuntimeListeners(listeners, (listener) => listener(next), 'pty-driver') + } + } + + // Why: the host's own fit cascade (window resize, split drag, tab reveal, + // "+"-new-tab re-render) must not resize a PTY whose width a remote client + // owns — that is the remote "porridge" bug. True while a phone (mobile driver) + // OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated + // here (see the `writePtyInput` mobile-only checks): shared-control desktop + // viewers may still type alongside the host. + // Note: this is intentionally NOT a driver kind. An active remote viewer needs + // only resize suppression, not the mobile driver machinery (input lock, + // phone-fit, driver-change banners), so it lives in its own registry and does + // not perturb the presence-lock state machine. It also coexists with mobile: + // while a phone drives, the registry still suppresses host resize, and when + // the phone leaves the surviving viewer keeps the PTY suppressed. + isPtyResizeDrivenRemotely(ptyId: string): boolean { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + return this.isRemoteDesktopResizeDriven(ptyId) + } + + isRemoteDesktopResizeDriven(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) + } + + isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { + return this.remoteDesktopOwners.get(ptyId) === subscriptionKey + } + + getRemoteDesktopFitHold( + ptyId: string, + subscriptionKey: string + ): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } { + const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 } + return { + mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey) + ? 'desktop-fit' + : 'remote-desktop-fit', + ...size + } + } + + private hasRemoteDesktopViewers(ptyId: string): boolean { + const viewers = this.remoteDesktopViewers.get(ptyId) + return viewers !== undefined && viewers.size > 0 + } + + private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null { + const owner = this.remoteDesktopOwners.get(ptyId) + return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null + } + + private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } { + const target = this.remoteDesktopHostReclaimTargets.get(ptyId) + if (target) { + return target + } + // Why: a viewer can join while a phone owns the actual PTY size. The + // mobile restore chain retains the pre-phone desktop geometry; current + // PTY size alone would incorrectly capture the phone grid as host truth. + return this.resolveDesktopRestoreTarget(ptyId) + } + + private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void { + if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set( + ptyId, + this.resolveRemoteDesktopHostReclaimTarget(ptyId) + ) + } + } + + recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void { + // Why: phone presence also suppresses host resize, but must not seed the + // separate remote-viewer cache when no desktop stream owns a width floor. + if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) { + return + } + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } + + private hasRemoteDesktopLayoutState(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId) + } + + private bumpRemoteDesktopViewerRevision(ptyId: string): number { + const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1 + this.remoteDesktopViewerRevisions.set(ptyId, revision) + return revision + } + + async applyRemoteDesktopLayout(ptyId: string): Promise { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + const target = this.activeRemoteDesktopViewport(ptyId) + const reclaimingHost = !target + const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0 + const layoutTarget: PtyLayoutTarget = target + ? { + kind: 'remote-desktop', + cols: target.cols, + rows: target.rows, + ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)! + } + : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, layoutTarget) + // Why: only drop the recorded host size once the reclaim resize actually + // landed. If it failed, the PTY is still at the remote-viewer width, so + // keep the target for the next reclaim (otherwise it resolves via the + // stale remote width and never restores true host geometry). + if ( + reclaimingHost && + result.ok && + !this.remoteDesktopOwners.has(ptyId) && + this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision + ) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + + // Why: attachment only records geometry. Passive hydration/reconnect must not + // steal the shared PTY from the desktop where the user is actively working. + async updateRemoteDesktopViewer( + ptyId: string, + subscriptionKey: string, + clientId: string, + cols: number, + rows: number, + claim = true + ): Promise { + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + viewers = new Map< + string, + { clientId: string; cols: number; rows: number; activity: number } + >() + this.remoteDesktopViewers.set(ptyId, viewers) + } + const prior = viewers.get(subscriptionKey) + if ( + prior && + prior.cols === viewport.cols && + prior.rows === viewport.rows && + (!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey) + ) { + if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + if (size?.cols !== viewport.cols || size.rows !== viewport.rows) { + return this.applyRemoteDesktopLayout(ptyId) + } + } + return true + } + const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0) + viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity }) + this.bumpRemoteDesktopViewerRevision(ptyId) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + return this.applyRemoteDesktopLayout(ptyId) + } + return true + } + + claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey) + if (!viewer) { + return Promise.resolve(false) + } + if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + return size?.cols === viewer.cols && size.rows === viewer.rows + ? Promise.resolve(true) + : this.applyRemoteDesktopLayout(ptyId) + } + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + viewer.activity = ++this.remoteDesktopActivity + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { + if (!this.remoteDesktopOwners.has(ptyId)) { + // Why: disconnect can remove the owner before its queued host resize + // lands. A host input in that window must join the reclaim, not pass it. + return this.remoteDesktopHostReclaimTargets.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + const viewport = clampTerminalViewport(cols, rows) + this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) + this.remoteDesktopOwners.delete(ptyId) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) + } + + unregisterRemoteDesktopViewers( + ptyId: string, + subscriptionKeys: Iterable + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + let changed = false + let removedOwner = false + for (const subscriptionKey of subscriptionKeys) { + removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner + changed = viewers.delete(subscriptionKey) || changed + } + if (!changed) { + return Promise.resolve(false) + } + if (viewers.size === 0) { + this.remoteDesktopViewers.delete(ptyId) + } + if (removedOwner) { + let fallback: { key: string; activity: number } | null = null + for (const [key, viewer] of viewers) { + if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) { + fallback = { key, activity: viewer.activity } + } + } + if (fallback) { + this.remoteDesktopOwners.set(ptyId, fallback.key) + } else { + this.remoteDesktopOwners.delete(ptyId) + } + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) + } + + // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so + // it must never *create* a width floor (that floor would leak — nothing + // releases it, pinning the host at a stale width after the viewer is gone). + // It only refreshes the floor(s) this client already owns via its stream + // subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport` + // no-op-without-subscription invariant. Returns false when the client owns no + // floor (passive/stream-less viewer) — a stream-less viewer must not lock host + // resize. + refreshRemoteDesktopViewer( + ptyId: string, + clientId: string, + cols: number, + rows: number, + claim = false + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + // Why: terminal.send may be the first activity while the stream is only + // passively registered. Snapshot host truth before this refresh owns it. + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let changed = false + for (const [subscriptionKey, viewer] of viewers) { + if (viewer.clientId === clientId) { + const activity = claim ? ++this.remoteDesktopActivity : viewer.activity + viewers.set(subscriptionKey, { + ...viewer, + cols: viewport.cols, + rows: viewport.rows, + activity + }) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + } + changed = true + } + } + if (!changed) { + return Promise.resolve(false) + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.remoteDesktopOwners.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + + async updateDesktopViewport( + ptyId: string, + viewport: { cols: number; rows: number } + ): Promise { + const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) + if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') { + this.recordRendererGeometry(ptyId, cols, rows) + return true + } + if (this.isResizeSuppressed()) { + return false + } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.refreshRendererGeometry(ptyId, cols, rows) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + + markMobileActor(ptyId: string, clientId: string): void { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (sub) { + sub.lastActedAt = Date.now() + } + this.setDriver(ptyId, { kind: 'mobile', clientId }) + } + + beginMobileInputFloor( + ptyId: string, + clientId: string + ): { commit: () => Promise; rollback: () => void } | null { + // Why: admit a client still inside its soft-leave grace (mirrors + // mobileTookFloor) so a write landing in that window reserves the floor + // instead of being dropped; post-grace/orphaned writers stay rejected. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { + return null + } + const state = this.mobileInputFloorClaims.get(ptyId) ?? { + base: this.getDriver(ptyId), + generation: 0, + committedGeneration: 0, + pending: new Map() + } + this.mobileInputFloorClaims.set(ptyId, state) + const token = Symbol('mobile-input-floor') + const generation = ++state.generation + state.pending.set(token, { clientId, generation }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + let settled = false + return { + commit: async () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + // Why: a newer accepted write owns the floor; an older claim that was + // delayed before commit must not replace its rollback baseline or driver. + if (generation < state.committedGeneration) { + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + return + } + const previousFloor = state.base + // Why: a successful write becomes the rollback baseline for any + // overlapping reservations that have not reached the PTY yet. + state.committedGeneration = generation + state.base = { kind: 'mobile', clientId } + await this.mobileTookFloor( + ptyId, + clientId, + previousFloor, + () => + this.mobileInputFloorClaims.get(ptyId) === state && + state.committedGeneration === generation + ) + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + }, + rollback: () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + if (this.mobileInputFloorClaims.get(ptyId) !== state) { + return + } + const current = this.getDriver(ptyId) + if (current.kind === 'mobile' && current.clientId === clientId) { + const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId + this.setDriver( + ptyId, + pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base + ) + } + if (state.pending.size === 0) { + this.mobileInputFloorClaims.delete(ptyId) + } + } + } + } + + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / + // resizeForClient / fresh subscribe with auto). Records the actor as the + // most recent mobile driver and re-applies phone-fit if we were previously + // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs + // are no-ops for resize. + async mobileTookFloor( + ptyId: string, + clientId: string, + previousFloor?: DriverState, + isCurrent: () => boolean = () => true + ): Promise { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + const softLeaver = this.pendingSoftLeavers.get(ptyId) + // Why: native chat pauses terminal output, so its later sends have no + // subscriber lifecycle that could release a newly-created desktop lock. + if (!sub && softLeaver?.clientId !== clientId) { + return + } + if (sub) { + sub.lastActedAt = Date.now() + } + const prev = previousFloor ?? this.getDriver(ptyId) + const currentMode = this.mobileDisplayModes.get(ptyId) + // Why: a deliberate mobile action implies mobile is resuming control. + // If the display mode is currently 'desktop' (set by an earlier + // take-back), flip it back to 'auto' (= map absence) and re-apply so + // phone-fit takes hold again. See docs/mobile-presence-lock.md. + if (prev.kind === 'desktop' || currentMode === 'desktop') { + if (currentMode === 'desktop') { + this.mobileDisplayModes.delete(ptyId) + } + await this.applyMobileDisplayMode(ptyId) + } + // Why: display changes are async; a later PTY write must keep the floor + // when an older phone-fit operation eventually completes. + if (!isCurrent()) { + return + } + this.setDriver(ptyId, { kind: 'mobile', clientId }) + } + + // Why: in-place viewport update on the existing mobile subscription — + // used when the mobile keyboard opens/closes and shrinks/grows the + // visible terminal area. We refresh the subscriber's viewport, re-fit + // the PTY to the new dims, and emit a 'resized' event so the mobile + // xterm reinits inline at the new dims without re-subscribing. This + // avoids the unsubscribe → resubscribe cycle which would (a) flash the + // desktop lock banner during the brief idle gap and (b) cause the new + // subscribe to capture the already-phone-fitted PTY size as its + // restore baseline (stuck-dim bug on later disconnect). + // No-op when the client isn't actually subscribed to this PTY. + async updateMobileViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): Promise<{ updated: boolean; applied: boolean }> { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (!sub) { + return { updated: false, applied: false } + } + sub.viewport = viewport + sub.lastActedAt = Date.now() + + const mode = this.getMobileDisplayMode(ptyId) + if (mode === 'desktop') { + // Watching at desktop dims — viewport is informational only. + return { updated: true, applied: false } + } + // Why: a desktop take-back is released only by a deliberate mobile gesture + // (mobileTookFloor / setDisplayMode / fresh subscribe). A passive viewport report + // — iOS resume and every reconnect force one — must not re-phone-fit and re-take + // the floor, or the take-back looks like a no-op to the desktop user. + if (this.getDriver(ptyId).kind === 'desktop') { + return { updated: true, applied: false } + } + // Drive PTY dims by the most-recent-actor (just updated to this client). + const winner = this.pickMostRecentActor(inner!) + if (!winner) { + return { updated: false, applied: false } + } + const winnerSub = inner!.get(winner.clientId) + const driveViewport = winnerSub?.viewport ?? viewport + const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( + driveViewport.cols, + driveViewport.rows + ) + + sub.wasResizedToPhone = true + // The driver is already mobile{this client} when we got here; refresh + // to update lastActedAt-based ordering on later actor selection. + this.setDriver(ptyId, { kind: 'mobile', clientId }) + + const needsFreshSubscribeGuard = !this.layouts.has(ptyId) + if (needsFreshSubscribeGuard) { + this.freshSubscribeGuard.add(ptyId) + } + let result: ApplyLayoutResult + try { + result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: winner.clientId + }) + } finally { + if (needsFreshSubscribeGuard) { + this.freshSubscribeGuard.delete(ptyId) + } + } + return { updated: true, applied: result.ok } + } + + // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take + // back" / "Restore" button). Forces the PTY back to desktop dims and flips + // the driver to `desktop`, suppressing further mobile-driven dim changes + // until a mobile actor takes the floor again. Three cases, each ending in + // releaseDesktopTakeBack: + // 1. Active mobile subscriber: route through applyMobileDisplayMode so the + // existing 'resized' event reaches the phone. + // 2. Held override, no subscriber (post-indefinite-hold): resolve the + // restore target and enqueueLayout directly. + // 3. Stale mobile driver, no subscriber and no override: nothing to resize, + // just drop the lock. See docs/mobile-fit-hold.md. + // + // Why: explicit desktop take-back is a user command to reclaim input control + // NOW. Unlike the auto-restore timer and phone-initiated setDisplayMode paths + // (which keep the lock when a resize can't converge, #7588), this gesture + // ALWAYS drops the presence lock and banner. "Take back all terminals" + // reclaims several PTYs at once; a background pane whose desktop resize can't + // converge must not strand its banner on the other terminals. The resize is + // best-effort — the desktop renderer refits the PTY on its next settled + // frame. Returns `true` whenever there was a lock to reclaim, `false` only + // when there was nothing to reclaim. + async reclaimTerminalForDesktop(ptyId: string): Promise { + this.cancelPendingDriverMutations(ptyId) + if (this.isMobileSubscriberActive(ptyId)) { + this.setMobileDisplayMode(ptyId, 'desktop') + await this.applyMobileDisplayMode(ptyId) + this.releaseDesktopTakeBack(ptyId) + // Why: a desktop-initiated reclaim is "I'm taking over right now", not a + // sticky preference. The next mobile subscribe (e.g. user switches back to + // the terminal tab on the phone) must default to phone-fit again, not stay + // in passive desktop-watch mode. + this.setMobileDisplayMode(ptyId, 'auto') + if (this.hasRemoteDesktopLayoutState(ptyId)) { + // Why: the lock is already released above, so this re-layout is + // best-effort. Reporting its `ok` would tell the desktop "nothing was + // reclaimed" and cost the caller its post-take-back refit and focus. + await this.applyRemoteDesktopLayout(ptyId) + } + return true + } + const heldOverride = this.terminalFitOverrides.get(ptyId) + if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { + // Why: applyRemoteDesktopLayout no-ops while the driver still reads mobile. + this.setDriver(ptyId, { kind: 'idle' }) + // Why: best-effort, like the local held branch below. A host whose resize + // keeps failing (dropped SSH/WSL provider, exited PTY) would otherwise + // roll the lock back and leave the banner stranded, making every retry a + // no-op — the one branch that broke this method's release guarantee. + await this.applyRemoteDesktopLayout(ptyId) + this.releaseDesktopTakeBack(ptyId) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + if (heldOverride) { + // Why: with no subscribers, resolveDesktopRestoreTarget can fall through + // to current PTY size — which is at phone dims (wrong). Prefer a fresh + // desktop renderer measurement when one exists; otherwise use the + // override's pre-fit baseline before falling back to current size. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const renderer = this.lastRendererSizes.get(ptyId) + const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols + const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows + await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + this.releaseDesktopTakeBack(ptyId) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + // Why: a stale lock — driver still reads mobile with no active subscriber + // and no held override (e.g. reclaimed inside the soft-leave grace, or a + // subscriber that dropped without a clean unsubscribe). Release it so the + // banner can't linger; there is nothing to resize. + if (this.getDriver(ptyId).kind === 'mobile') { + this.releaseDesktopTakeBack(ptyId) + return true + } + return false + } + + // Why: teardown and desktop reclaim supersede delayed mobile mutations, + // revoking soft-leave grace admission for input floors. + private cancelPendingDriverMutations(ptyId: string): void { + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const pendingSoft = this.pendingSoftLeavers.get(ptyId) + if (pendingSoft) { + clearTimeout(pendingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + } + + // Why: the shared "banner must be gone now" step for an explicit desktop + // take-back. Releases the presence lock (driver → desktop) and, if the + // best-effort resize left a fit-override held (resize didn't converge), + // clears it optimistically with a paired desktop-fit 0×0 — the same signal + // onPtyExit emits — so neither the presence-lock banner nor the held-fit + // banner can survive the reclaim. The desktop renderer refits the PTY to real + // dims on its next settled frame. + private releaseDesktopTakeBack(ptyId: string): void { + this.setDriver(ptyId, { kind: 'desktop' }) + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + } + + // Why: read-side clamp for mobileAutoRestoreFitMs. `null` means + // indefinite hold (no auto-restore timer). A finite value is clamped + // to [MIN, MAX] to defend against bad config — the smallest useful + // value is a few seconds, the largest is one hour. See + // docs/mobile-fit-hold.md. + private getAutoRestoreFitMs(): number | null { + const raw = this.store?.getSettings().mobileAutoRestoreFitMs ?? null + if (raw == null) { + return null + } + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null + } + return Math.min(Math.max(raw, MOBILE_AUTO_RESTORE_FIT_MIN_MS), MOBILE_AUTO_RESTORE_FIT_MAX_MS) + } + + // Why: invoked when the user changes mobileAutoRestoreFitMs to `null` + // (Indefinite). Clears every pending restore timer so the just-expressed + // preference "do not auto-restore" is honored for ALL currently-pending + // PTYs, not just one. See docs/mobile-fit-hold.md. + cancelAllPendingFitRestoreTimers(): void { + for (const [, entry] of this.pendingRestoreTimers) { + clearTimeout(entry.timer) + } + this.pendingRestoreTimers.clear() + } + + // Why: read the persisted user preference (clamped) for surfacing to UI + // callers (mobile RPC, desktop preferences). Returns null when the + // setting is unset or `null` ("Indefinite"). + getMobileAutoRestoreFitMs(): number | null { + return this.getAutoRestoreFitMs() + } + + // Why: persisted-preference setter routed through the same `Store` the + // desktop preferences UI writes to. Transitions to `null` (Indefinite) + // clear every pending restore timer to honor the preference change for + // already-held PTYs. Transitions to a finite value do NOT retroactively + // schedule timers for PTYs that are currently held — those PTYs were + // already-not-restored under the old preference, and silently scheduling + // a restore on a settings change would be surprising. The new value + // takes effect on the next unsubscribe. See docs/mobile-fit-hold.md. + setMobileAutoRestoreFitMs(ms: number | null): number | null { + if (!this.store?.updateSettings) { + return this.getAutoRestoreFitMs() + } + let normalized: number | null + if (ms == null) { + normalized = null + } else if (typeof ms !== 'number' || !Number.isFinite(ms)) { + normalized = null + } else { + normalized = Math.min( + Math.max(ms, MOBILE_AUTO_RESTORE_FIT_MIN_MS), + MOBILE_AUTO_RESTORE_FIT_MAX_MS + ) + } + this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }, { notifyListeners: true }) + if (normalized == null) { + this.cancelAllPendingFitRestoreTimers() + } + return normalized + } + + // Why: with multiple subscribers, the active phone-fit dims follow the + // most recent mobile actor (argmax(lastActedAt)). See + // docs/mobile-presence-lock.md "Active phone-fit dim selection". + private pickMostRecentActor( + inner: Map + ): { clientId: string; lastActedAt: number } | null { + let best: { clientId: string; lastActedAt: number } | null = null + for (const sub of inner.values()) { + if (best === null || sub.lastActedAt > best.lastActedAt) { + best = sub + } + } + return best + } + + // Why: restore-target selection on last-subscriber-leaves picks the + // earliest-by-subscribe-time subscriber AMONG those with non-null + // previousCols/Rows. Desktop-mode joins carry null and are skipped — they + // never captured pre-fit dims by design. + private pickEarliestRestoreTarget( + inner: Map< + string, + { subscribedAt: number; previousCols: number | null; previousRows: number | null } + > + ): { previousCols: number; previousRows: number } | null { + let best: { subscribedAt: number; previousCols: number; previousRows: number } | null = null + for (const sub of inner.values()) { + if (sub.previousCols == null || sub.previousRows == null) { + continue + } + if (best === null || sub.subscribedAt < best.subscribedAt) { + best = { + subscribedAt: sub.subscribedAt, + previousCols: sub.previousCols, + previousRows: sub.previousRows + } + } + } + return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null + } + + // ─── Layout state machine ───────────────────────────────────────── + // + // See docs/mobile-terminal-layout-state-machine.md. + // + // applyLayout is the SOLE writer of: + // - this.layouts + // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in + // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) + // - this.ptyController.resize (i.e. the actual PTY dims) + // + // Every trigger that wants to change PTY dims or flip mode goes through + // enqueueLayout, which serializes calls behind a per-PTY async queue + // (the await on ptyController.resize would otherwise let seq bumps reach + // the wire out of order). + + getLayout(ptyId: string): PtyLayoutState | null { + return this.layouts.get(ptyId) ?? null + } + + // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire + // on the very first transition for a PTY (where the entry doesn't exist + // yet *because* we're about to create it). handleMobileSubscribe adds + // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and + // removes it in a finally block. + private isFreshSubscribe(ptyId: string): boolean { + return this.freshSubscribeGuard.has(ptyId) + } + + // Why: four-step fallback chain for desktop-restore targets. Always + // returns a value; the terminal {80,24} branch is reached only under + // bug. Wrapping the chain as a single helper prevents callsite drift. + private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { + // 1. Earliest-by-subscribedAt subscriber with non-null baseline. + const inner = this.mobileSubscribers.get(ptyId) + if (inner) { + const earliest = this.pickEarliestRestoreTarget(inner) + if (earliest) { + return { cols: earliest.previousCols, rows: earliest.previousRows } + } + } + // 2. Most-recent desktop renderer geometry report. + const renderer = this.lastRendererSizes.get(ptyId) + if (renderer) { + return { cols: renderer.cols, rows: renderer.rows } + } + // 3. Current PTY size. + const size = this.getTerminalSize(ptyId) + if (size) { + return { cols: size.cols, rows: size.rows } + } + // 4. Hard default. + return { cols: 80, rows: 24 } + } + + // Why: a new viewport-only update from the same owner supersedes a + // queued same-shape tail. Mode flips, owner changes, and take-back + // append (losing a take-floor to a viewport tick would be a fairness + // hole — see "enqueueLayout coalescing" in the design doc). + private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { + if (prev.kind !== next.kind) { + return false + } + if (prev.kind === 'phone' && next.kind === 'phone') { + return prev.ownerClientId === next.ownerClientId + } + if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') { + // Why: each owner's claim promise gates its following input. Sharing a + // waiter across owners could release A's input only after B's grid lands. + return prev.ownerSubscriptionKey === next.ownerSubscriptionKey + } + return true + } + + private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first + // transition through even though `layouts` has no entry yet. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return Promise.resolve({ ok: false, reason: 'pty-exited' }) + } + + let entry = this.layoutQueues.get(ptyId) + if (!entry) { + entry = { running: null, pending: [] } + this.layoutQueues.set(ptyId, entry) + } + const queue = entry + + return new Promise((resolve) => { + if (!queue.running) { + queue.running = this.runLayoutSlot(ptyId, target, [resolve]) + return + } + const tail = queue.pending.at(-1) + if (tail && this.coalescesWith(tail.target, target)) { + tail.target = target + tail.waiters.push(resolve) + return + } + queue.pending.push({ target, waiters: [resolve] }) + }) + } + + private async runLayoutSlot( + ptyId: string, + target: PtyLayoutTarget, + waiters: ((r: ApplyLayoutResult) => void)[] + ): Promise { + let result: ApplyLayoutResult + try { + result = await this.applyLayout(ptyId, target) + } catch (err) { + // Why: defensive — applyLayout itself catches resize errors, but a + // throw from one of the synchronous map writes (e.g. notifier hook) + // must not jam the queue forever. + console.error('[layout] applyLayout threw', { ptyId, err }) + result = { ok: false, reason: 'resize-failed' } + } + for (const w of waiters) { + w(result) + } + + const queue = this.layoutQueues.get(ptyId) + if (!queue) { + return result + } + const next = queue.pending.shift() + if (next) { + queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + } else { + queue.running = null + // Why: drop the entry once empty so the map doesn't grow without bound + // across short-lived PTYs. + this.layoutQueues.delete(ptyId) + } + return result + } + + private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: re-check pty-exit at the head of the slot — the queue may have + // accepted this target before onPtyExit ran. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return { ok: false, reason: 'pty-exited' } + } + + const prev = this.layouts.get(ptyId) ?? null + const seq = (prev?.seq ?? 0) + 1 + const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } + + const currentSize = this.getTerminalSize(ptyId) + const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows + const modeChanged = (prev?.kind ?? 'desktop') !== target.kind + + // Snapshot for rollback. + const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null + + // Tentative writes — the resize is the point of no return. + this.layouts.set(ptyId, next) + if (target.kind === 'phone') { + // Why: pull baseline cols+rows atomically from the same subscriber so + // they can't desync. + const baseline = (() => { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return null + } + return this.pickEarliestRestoreTarget(inner) + })() + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: target.cols, + rows: target.rows, + previousCols: baseline?.previousCols ?? null, + previousRows: baseline?.previousRows ?? null, + updatedAt: next.appliedAt, + clientId: target.ownerClientId + }) + } else { + this.terminalFitOverrides.delete(ptyId) + } + + if (dimsChanged) { + let ok = false + try { + const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) + ok = r ?? true + } catch (err) { + console.error('[layout] ptyController.resize threw', { ptyId, err }) + ok = false + } + if (!ok) { + // Roll back to pre-call snapshot. seq is NOT bumped on the wire + // because we never emit below. + if (prev) { + this.layouts.set(ptyId, prev) + } else { + this.layouts.delete(ptyId) + } + if (prevFitOverride) { + this.terminalFitOverrides.set(ptyId, prevFitOverride) + } else { + this.terminalFitOverrides.delete(ptyId) + } + return { ok: false, reason: 'resize-failed' } + } + this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) + } + + // Why: remote desktop ownership is a fit hold for the host and passive + // peer viewers. Emit every remote layout so owner changes at equal geometry + // still park/release the correct clients without relying on resize deltas. + // Defense-in-depth (#7588): also emit when the override's presence + // changed even without a kind flip. applyLayout is the sole writer and + // keeps override presence in lockstep with layout kind, so overrideChanged + // ≡ modeChanged in every reachable state today; the extra clause fires + // only if that invariant is ever violated, repairing the renderer instead + // of stranding the held modal. + const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') + if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) { + // Why: phone→desktop arms the renderer-cascade suppress window + // before the collateral safeFit IPCs arrive. See "Renderer cascade + // suppression". + if (target.kind === 'desktop') { + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + } + this.notifier?.terminalFitOverrideChanged( + ptyId, + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', + target.cols, + target.rows + ) + this.notifyFitOverrideListeners( + ptyId, + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', + target.cols, + target.rows + ) + } + + // Mobile-facing event always fires (phone clients need to re-fit on + // every dim change, not just mode flips). + this.notifyTerminalResize(ptyId, { + cols: target.cols, + rows: target.rows, + displayMode: target.kind === 'phone' ? 'phone' : 'desktop', + reason: 'apply-layout', + seq + }) + + return { ok: true, state: next } + } + + // ─── Server-Authoritative Mobile Display Mode ───────────────────── + + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { + if (mode === 'auto') { + this.mobileDisplayModes.delete(ptyId) + } else { + this.mobileDisplayModes.set(ptyId, mode) + } + } + + getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { + return this.mobileDisplayModes.get(ptyId) ?? 'auto' + } + + isMobileSubscriberActive(ptyId: string): boolean { + const inner = this.mobileSubscribers.get(ptyId) + return inner !== undefined && inner.size > 0 + } + + // Why: late-bind viewport on an existing subscriber record. Subscribers + // that registered before the mobile side measured (e.g. terminal first + // mounted while the WebView was still loading) have null viewport, and + // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. + // The setDisplayMode RPC carries the latest viewport so we can patch it + // here just before applyMobileDisplayMode runs. + updateMobileSubscriberViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): void { + const inner = this.mobileSubscribers.get(ptyId) + const record = inner?.get(clientId) + if (!record) { + return + } + record.viewport = viewport + } + + // Why: server-side auto-fit on mobile subscribe. The runtime is the single + // source of truth — the mobile client just passes its viewport and the runtime + // decides whether to resize. This eliminates the measure→RPC→resubscribe + // pipeline that caused race conditions. + // + // Multi-mobile keying: each subscriber lives in `mobileSubscribers[ptyId]`'s + // inner map under its own clientId. Phone B subscribing does not overwrite + // phone A's record — both stay until each unsubscribes. + // + // Subscribe-in-desktop-mode rule: a subscribe with displayMode='desktop' is + // a passive watch; it does NOT take the floor. The driver remains + // `idle`/`desktop`. The lock banner is reserved for actual mobile + // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). + async handleMobileSubscribe( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } + ): Promise { + try { + return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) + } finally { + // Every subscribe path mutates mobileSubscribers — resync the daemon + // background mark once, whatever branch returned. + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + private async handleMobileSubscribeInternal( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } + ): Promise { + const mode = this.getMobileDisplayMode(ptyId) + + // Cancel pending restore timer for this ptyId — any new subscriber + // supersedes any old client's pending restore. + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + // Resubscribe-grace honor: same client returning within soft-leave + // window restores prior record (preserving baseline so we don't capture + // phone-fitted dims as the new baseline). + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (softLeaver && softLeaver.clientId === clientId) { + clearTimeout(softLeaver.timer) + this.pendingSoftLeavers.delete(ptyId) + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + inner.set(clientId, { + ...softLeaver.record, + viewport: viewport ?? null, + lastActedAt: Date.now() + }) + if (!viewport) { + return false + } + this.setDriver(ptyId, { kind: 'mobile', clientId }) + if (mode !== 'desktop') { + const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( + viewport.cols, + viewport.rows + ) + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + return true + } + + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + + // Capture restore baseline BEFORE applyLayout writes the override. + // Multi-mobile: peer joiner against an already-fitted PTY captures null + // — the existing baseline-holder's snapshot remains canonical. See + // docs/mobile-presence-lock.md. + // + // Resubscribe-after-indefinite-hold: the held override carries the only + // authoritative pre-fit dims across the no-subscriber gap. Inherit it + // first; otherwise rendererSize/currentSize would be the held phone dims + // and applyLayout would clobber the override's previousCols with phone + // dims, making any subsequent Restore a no-op. + const heldOverride = this.terminalFitOverrides.get(ptyId) + const existing = inner.get(clientId) + const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) + const currentSize = this.getTerminalSize(ptyId) + const rendererSize = this.lastRendererSizes.get(ptyId) + const previousCols = + existing?.previousCols ?? + heldOverride?.previousCols ?? + (someoneAlreadyFitted ? null : (rendererSize?.cols ?? currentSize?.cols ?? null)) + const previousRows = + existing?.previousRows ?? + heldOverride?.previousRows ?? + (someoneAlreadyFitted ? null : (rendererSize?.rows ?? currentSize?.rows ?? null)) + const now = Date.now() + const subscribedAt = existing?.subscribedAt ?? now + + if (!viewport) { + // Why: mobile can subscribe before its WebView has measured. Keep the + // subscriber + desktop baseline so updateViewport/setDisplayMode can + // late-bind the viewport without recapturing phone dims. + inner.set(clientId, { + clientId, + viewport: null, + wasResizedToPhone: false, + previousCols, + previousRows, + subscribedAt, + lastActedAt: now + }) + return false + } + + const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( + viewport.cols, + viewport.rows + ) + + if (mode === 'desktop') { + // Passive watch — null baseline (we'll capture later if user toggles + // to auto/phone, since safeFit will have converged by then). Do not + // flip driver. + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: false, + previousCols: null, + previousRows: null, + subscribedAt, + lastActedAt: now + }) + return false + } + + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: true, + previousCols, + previousRows, + subscribedAt, + lastActedAt: now + }) + + // Subscribe-fresh with auto/phone counts as "take the floor". + this.setDriver(ptyId, { kind: 'mobile', clientId }) + + // Route the actual resize through the state machine. The fresh-subscribe + // gate lets enqueueLayout's "no layouts entry" short-circuit pass on + // the very first transition for this PTY. + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + + return true + } + + // Why: delayed restore prevents resize thrashing during rapid tab switches. + // The 300ms debounce means only the final tab triggers a PTY restore; + // intermediate terminals keep their current dims harmlessly. + // + // Multi-mobile: only the last subscriber leaving for this ptyId triggers + // restore + driver=idle. Peer mobile clients still on the inner map keep + // the lock banner mounted; if the disconnecting client was the active + // driver, we re-elect the most-recent surviving subscriber. + handleMobileUnsubscribe(ptyId: string, clientId: string): void { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + const subscriber = inner.get(clientId) + if (!subscriber) { + return + } + const wasResizedToPhone = subscriber.wasResizedToPhone + + inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + + if (inner.size > 0) { + // Why: if the leaving client was the only one with a non-null restore + // baseline (typical when peer joiners subscribed against an + // already-phone-fitted PTY and got null prevCols), donate the baseline + // to the earliest surviving subscriber so a future last-leaver can + // still restore correctly. See docs/mobile-presence-lock.md. + if ( + subscriber.previousCols != null && + subscriber.previousRows != null && + !this.pickEarliestRestoreTarget(inner) + ) { + let earliestSurvivor: { clientId: string; subscribedAt: number } | null = null + for (const sub of inner.values()) { + if (earliestSurvivor === null || sub.subscribedAt < earliestSurvivor.subscribedAt) { + earliestSurvivor = { clientId: sub.clientId, subscribedAt: sub.subscribedAt } + } + } + if (earliestSurvivor) { + const heir = inner.get(earliestSurvivor.clientId) + if (heir) { + heir.previousCols = subscriber.previousCols + heir.previousRows = subscriber.previousRows + } + } + } + // Peers still on the line. If the disconnecting client was the active + // mobile driver, re-elect the most-recent surviving subscriber so the + // banner remains correct and active phone-fit dims follow them. + const driver = this.getDriver(ptyId) + if (driver.kind === 'mobile' && driver.clientId === clientId) { + const next = this.pickMostRecentActor(inner) + if (next) { + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout + // failures self-recover on the next gesture. + void this.applyMobileDisplayMode(ptyId) + } + } + return + } + + // Last subscriber leaving — clean up. + this.mobileSubscribers.delete(ptyId) + const mode = this.getMobileDisplayMode(ptyId) + + // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick + // re-subscribe (older clients without updateViewport) doesn't flash the + // desktop banner. See docs/mobile-presence-lock.md. + const SOFT_LEAVE_GRACE_MS = 250 + const existingSoft = this.pendingSoftLeavers.get(ptyId) + if (existingSoft) { + clearTimeout(existingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + const softTimer = setTimeout(() => { + this.pendingSoftLeavers.delete(ptyId) + if (!this.mobileSubscribers.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + if (this.hasRemoteDesktopViewers(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + } + } + }, SOFT_LEAVE_GRACE_MS) + if (typeof softTimer.unref === 'function') { + softTimer.unref() + } + this.pendingSoftLeavers.set(ptyId, { + clientId, + timer: softTimer, + record: { + clientId: subscriber.clientId, + viewport: subscriber.viewport, + wasResizedToPhone: subscriber.wasResizedToPhone, + previousCols: subscriber.previousCols, + previousRows: subscriber.previousRows, + subscribedAt: subscriber.subscribedAt, + lastActedAt: subscriber.lastActedAt + } + }) + + if (mode === 'auto' && wasResizedToPhone) { + const existingTimer = this.pendingRestoreTimers.get(ptyId) + if (existingTimer) { + clearTimeout(existingTimer.timer) + this.pendingRestoreTimers.delete(ptyId) + } + // Why: scheduling is conditional on the user's mobileAutoRestoreFitMs + // preference. `null` (default, "Indefinite") leaves the PTY at phone + // dims until the user clicks Restore on the desktop banner — the + // central UX promise of docs/mobile-fit-hold.md. A finite value runs + // the restore that long after the last unsubscribe. + const autoRestoreMs = this.getAutoRestoreFitMs() + if (autoRestoreMs == null) { + // Indefinite hold: the fit override persists, the SOFT_LEAVE_GRACE + // driver-state grace above still releases the input lock, and the + // banner's Restore button is the explicit return path. + } else { + // Snapshot the disconnecting subscriber's baseline NOW, before the + // timer fires. By the time the timer runs, the subscriber map has + // been deleted; resolveDesktopRestoreTarget would fall through to + // lastRendererSizes → current PTY size (which is at phone dims, + // wrong). The disconnecting subscriber's baseline is the correct + // restore target. + const fallback = this.lastRendererSizes.get(ptyId) + const restoreCols = + subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 + const restoreRows = + subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 + const timer = setTimeout(() => { + this.pendingRestoreTimers.delete(ptyId) + if (this.isMobileSubscriberActive(ptyId)) { + return + } + if (this.hasRemoteDesktopLayoutState(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + return + } + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restoreCols, + rows: restoreRows + }) + }, autoRestoreMs) + // Why: a delayed mobile restore should not keep Electron main alive + // after the last window/runtime transport has otherwise shut down. + if (typeof timer.unref === 'function') { + timer.unref() + } + + this.pendingRestoreTimers.set(ptyId, { timer, clientId }) + } + } + // 'desktop' mode: was never resized, nothing to restore. + } + + // Why: called when mode changes via terminal.setDisplayMode. Applies the + // mode change immediately if there's an active subscriber, and emits a + // 'resized' event so the mobile client can reinitialize xterm inline. + // + // Multi-mobile: the most recent mobile actor's viewport drives the active + // phone-fit dims. The earliest-by-subscribe-time subscriber's + // previousCols/Rows drive the desktop-restore target. + // + // Returns the post-condition "no fit-override remains held" (#7588): `true` + // when it cleared a held override OR nothing was held to begin with, `false` + // only when a restore was attempted and the resize failed (override rolled + // back, still held). Informational for every caller today — + // reclaimTerminalForDesktop deliberately does NOT gate on it, because an + // explicit take-back must drop the lock even when the resize cannot + // converge. Do not reinstate a convergence gate there. + async applyMobileDisplayMode(ptyId: string): Promise { + const mode = this.getMobileDisplayMode(ptyId) + const inner = this.mobileSubscribers.get(ptyId) + const subscriber = inner ? this.pickMostRecentActor(inner) : null + const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null + + if (mode === 'desktop') { + // Reset wasResizedToPhone on every fitted subscriber so a future + // toggle back to auto re-issues the resize. applyLayout owns the + // actual PTY resize + override delete + renderer notify. Track which + // subscribers we cleared so a failed resize can re-arm them. + const clearedFitSubscribers = inner + ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) + : [] + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = false + } + const anyWasResized = clearedFitSubscribers.length > 0 + // Why (#7588): also restore when a fit-override is still held but no + // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe + // after an indefinite hold resets the flag yet leaves the override, + // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget + // (the same resolver the anyWasResized branch uses) so the two adjacent + // restore paths can never resolve to different dims for the same state. + if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { + const restore = this.resolveDesktopRestoreTarget(ptyId) + const result = await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + // Why (#7588): a failed resize rolls the override back (still held), so + // re-arm the flags we cleared. Otherwise a later unsubscribe under a + // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip + // scheduling its auto-restore timer, and strand the held phone-fit. + if (!result.ok) { + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = true + } + } + } else { + // Nothing was fitted or held — emit a mode-change resize event so + // the mobile client still learns the toggle landed. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + } else { + // mode === 'auto' — the only non-desktop mode after the 'phone' + // (sticky-fit) collapse. Phone-fit if the active subscriber has a + // viewport and we haven't already applied it. + if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { + const viewport = subscriberRecord.viewport + if (viewport) { + await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + // After a phone-fit an override IS held, so this reports false. The + // auto branch is never reached from reclaim (it sets 'desktop' + // first); computed here only to keep the post-condition uniform. + return !this.terminalFitOverrides.has(ptyId) + } + } + // Why: always emit the mode change even when no resize occurred — the + // mobile client needs to learn the toggle landed even if dims didn't + // actually change. Carry the current seq (or undefined if no layout + // entry yet) so the mobile-side stale-event filter behaves correctly. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'auto', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + return !this.terminalFitOverrides.has(ptyId) + } + + // Why: called after a desktop renderer path has successfully resized the + // PTY (local IPC or remote desktop viewport). The runtime mirror must take + // the same accepted geometry so hidden-output restore parses at PTY width. + onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` + // before calling here, but defend against callers that don't. + if (this.isResizeSuppressed()) { + return + } + // Why: while a mobile-fit override is in place, the desktop renderer's + // safeFit echoes pty:resize(override.cols, override.rows). Treating that + // echo as legitimate geometry would overwrite each subscriber's + // previousCols/Rows baseline with phone dims, so the next take-back + // enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm + // stuck. Only filter reports that EXACTLY match the override — a fresh + // measurement from a now-visible pane (e.g. user activated a previously + // hidden tab on desktop, container went 0×0 → 1782×1195) reports + // different dims and is the right baseline to remember. + const activeOverride = this.terminalFitOverrides.get(ptyId) + if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { + return + } + // Why: a successful host resize supersedes any target retained after a + // failed viewer reclaim; a later viewer cycle must capture this new truth. + if (!this.hasRemoteDesktopViewers(ptyId)) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } + this.resizeHeadlessTerminal(ptyId, cols, rows) + this.refreshRendererGeometry(ptyId, cols, rows) + } + + // Why: pty:reportGeometry IPC sibling. The renderer calls this when a + // desktop pane container goes from 0×0 to a real size while a mobile-fit + // override is active (e.g. user activates a previously-hidden tab on + // desktop after the phone has already taken the floor). We need the + // restore-target baseline to track real desktop dims even during the + // fit period — otherwise resolveDesktopRestoreTarget falls back to the + // PTY's spawn default (typically 80×24) and Take Back leaves the + // terminal partially restored. This is a measurement-only channel: it + // refreshes lastRendererSizes and non-null subscriber baselines, never + // resizes the PTY, and bypasses both isResizeSuppressed and the + // override-echo gate by design — the renderer only fires it when it + // has just measured fresh real geometry. See docs/mobile-fit-hold.md. + recordRendererGeometry(ptyId: string, cols: number, rows: number): void { + if (cols <= 0 || rows <= 0) { + return + } + // Why: a viewer may leave while phone-fit still owns the PTY. Keep its + // deferred host reclaim cache aligned with later trusted pane measurements. + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } + this.refreshRendererGeometry(ptyId, cols, rows) + } + + // Why: test seam — exposes lastRendererSizes for assertions about + // pty:reportGeometry / onExternalPtyResize side effects without making + // the underlying Map writable from the outside. + getLastRendererSize(ptyId: string): { cols: number; rows: number } | null { + return this.lastRendererSizes.get(ptyId) ?? null + } + + private refreshRendererGeometry(ptyId: string, cols: number, rows: number): void { + this.lastRendererSizes.set(ptyId, { cols, rows }) + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + // Refresh the renderer-current size as the next-restore target on every + // subscriber that already has a non-null baseline. Subscribers with null + // baselines (joined while a peer had already phone-fitted) stay null. + for (const sub of inner.values()) { + if (sub.previousCols != null && sub.previousRows != null) { + sub.previousCols = cols + sub.previousRows = rows + } + } + } + + // Why: the pty:resize IPC handler calls this to check if the global + // suppress window is active. During this window, all desktop renderer + // pty:resize events are ignored to prevent collateral safeFit corruption. + isResizeSuppressed(): boolean { + return Date.now() < this.resizeSuppressedUntil + } + + private suppressResizesForMs(ms: number): void { + this.resizeSuppressedUntil = Date.now() + ms + } + + subscribeToTerminalResize( + ptyId: string, + listener: (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + ): () => void { + return addListenerToMap(this.resizeListeners, ptyId, listener) + } + + private notifyTerminalResize( + ptyId: string, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ): void { + const listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + return + } + notifyRuntimeListeners(listeners, (listener) => listener(event), 'pty-resize') + } + + // Why: Section 7.2 — the runtime detects agent exit directly and updates + // dispatch contexts immediately, rather than waiting for the coordinator's + // next poll cycle. This catches agent crashes and unexpected exits within + // milliseconds. The task is set back to 'pending' so it can be re-dispatched. + private failActiveDispatchOnExit( + handle: string, + paneKey: string | null, + exitCode: number, + cause: TerminalExitCause + ): void { + if (!this._orchestrationDb) { + return + } + + // Why the pane key too: a reminted handle no longer matches the row, but the + // pane identity behind it outlives the remint. + const dispatch = this._orchestrationDb.getActiveDispatchForTerminal( + handle, + paneKey ?? undefined + ) + if (!dispatch) { + return + } + + const errorContext = describeTerminalExitCause(cause) + const settled = this._orchestrationDb.failDispatch(dispatch.id, errorContext, { + workerProcessExited: true, + terminationReason: cause.kind + }) + + // Why: a deliberate close is not an incident. Escalating it trains + // coordinators to ignore the channel that should wake them for a real one. + if (isDeliberateTerminalExit(cause)) { + return + } + + // Why: failDispatch above is the authoritative state transition and has already + // committed. Everything below is best-effort mail on top of it — resolving the + // recipient reads the database too — and onPtyExit runs this synchronously per leaf, + // so letting any of it escape would abandon the remaining leaves and the pty record + // pruning that close out this exit. + try { + // Why: create an escalation message so the coordinator is notified about + // the unexpected exit, even if the circuit breaker hasn't tripped yet. + const recipient = this.resolveExitEscalationRecipient(dispatch.run_id) + if (!recipient) { + return + } + const escalation = this._orchestrationDb.insertMessage({ + from: handle, + to: recipient.to, + subject: `Agent exited unexpectedly (${errorContext})`, + body: this.describeWorkerExit(dispatch, cause, handle, settled?.status), + type: 'escalation', + priority: 'high', + // Why: applyEscalationToDispatch rejects an escalation without an exact Dispatch + // binding, and a coordinator reading this needs to know which Dispatch died. + payload: JSON.stringify({ + taskId: dispatch.task_id, + dispatchId: dispatch.id, + // Why both: `exitCode` stays for readers that already parse it, but it + // is the raw number the host handed over, not a verdict — `exitCause` + // is what says whether the agent was killed, finished, or was closed. + exitCode, + exitCause: cause, + handle + }), + ...(recipient.runId ? { runId: recipient.runId } : {}) + }) + // Why: worker death is the one escalation nobody will poll for — the dead pane + // can't nudge the coordinator, so wake its check --wait the way every other + // message producer does. + this.notifyMessageArrived(escalation.to_handle, escalation.type) + } catch (error) { + // Why: log the Run rather than the recipient — resolution itself can be what failed. + console.warn('[orchestration] failed to escalate worker exit', { + dispatchId: dispatch.id, + runId: dispatch.run_id, + error + }) + } + } + + // Why: the banner shows the subject and a raw payload, so without prose the coordinator + // has to resolve ids by hand to learn what died and whether the task is still retryable. + private describeWorkerExit( + dispatch: { id: string; task_id: string; run_id: string }, + cause: TerminalExitCause, + handle: string, + settledStatus: DispatchStatus | undefined + ): string { + const task = this._orchestrationDb?.getTask?.(dispatch.task_id, dispatch.run_id) + const title = + typeof task?.spec === 'string' + ? buildOrchestrationTaskDisplayMetadata({ + spec: task.spec, + taskTitle: task.task_title, + displayName: task.display_name + }).taskTitle + : '' + const named = title ? `"${title}" (${dispatch.task_id})` : dispatch.task_id + const outcome = + settledStatus === 'circuit_broken' + ? ' This task has now failed too many times, so it will not be retried automatically.' + : settledStatus === 'failed' + ? ' The task is ready to be dispatched again.' + : '' + // Why the cause and not a code: `code 0` reads as success even when the + // worker was killed, which is what sent operators chasing phantom OOMs. + return `Worker ${handle} stopped while running task ${named}. ${describeTerminalExitCause( + cause + )}.${outcome}` + } + + // Why: a lightweight Run keeps its coordinator in runs/run_coordinator_handles and + // never writes the legacy coordinator_runs table, so gating solely on that table + // dropped every worker-death escalation (STA-4604). Address the Run mailbox the + // coordinator's `orchestration check` actually reads, and leave legacy Runs on the + // legacy gate. + private resolveExitEscalationRecipient( + runId: string + ): { to: string; runId?: string } | undefined { + const owningRun = this._orchestrationDb?.getRun?.(runId) + if (owningRun && owningRun.legacy !== 1) { + return { to: `run:${owningRun.id}`, runId: owningRun.id } + } + const legacyRun = this._orchestrationDb?.getActiveCoordinatorRun?.() + return legacyRun ? { to: legacyRun.coordinator_handle } : undefined + } + + async listTerminals( + worktreeSelector?: string, + limit = DEFAULT_TERMINAL_LIST_LIMIT, + opts: { + handles?: readonly string[] + requireFreshPtyLiveness?: boolean + includeVisualLayouts?: boolean + } = {} + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null + const explicitTargetWorktreeId = worktreeSelector + ? this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) + : null + const initialResolvedWorktreeCache = this.resolvedWorktreeCache + const cachedResolvedWorktrees = + initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now() + ? initialResolvedWorktreeCache.worktrees + : null + const cachedExplicitTargetWorktree = + explicitTargetWorktreeId && cachedResolvedWorktrees + ? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ?? + null) + : null + const parsedExplicitTargetWorktree = + explicitTargetWorktreeId && !cachedExplicitTargetWorktree + ? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId) + : null + const targetWorktree = + worktreeSelector && !explicitTargetWorktreeId + ? await this.resolveWorktreeSelector(worktreeSelector) + : (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree) + const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null + const classificationResolvedWorktreeCache = this.resolvedWorktreeCache + const classificationResolvedWorktrees = + targetWorktreeId && + classificationResolvedWorktreeCache && + classificationResolvedWorktreeCache.expiresAt > Date.now() + ? includeTargetResolvedWorktree( + classificationResolvedWorktreeCache.worktrees, + targetWorktree + ) + : targetWorktreeId && explicitTargetWorktreeId + ? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) + : null + const worktreesById = + targetWorktreeId && targetWorktree + ? new Map([[targetWorktree.id, targetWorktree]]) + : targetWorktreeId + ? new Map() + : await this.getResolvedWorktreeMap() + if (graphEpoch !== null) { + this.assertStableReadyGraph(graphEpoch) + } + + const resolvedWorktrees = + targetWorktreeId && classificationResolvedWorktrees + ? classificationResolvedWorktrees + : targetWorktreeId && targetWorktree + ? [targetWorktree] + : targetWorktreeId + ? [] + : [...worktreesById.values()] + const controllerInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId + ) + const refreshedPtyLiveness = controllerInventory + ? new Set(controllerInventory.livePtyIds) + : null + if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + // Why: a proof of absence, not a proof of liveness — leaves whose PTY the + // controller answered for but did not list must not read as connected. An + // unavailable inventory (null) proves nothing and demotes nothing. + const provenLivePtyIds = controllerInventory?.allLivePtyIds ?? null + + const livePtyWorktreeIds = new Set() + for (const pty of this.ptysById.values()) { + if (pty.connected) { + livePtyWorktreeIds.add(pty.worktreeId) + } + } + + const terminals: RuntimeTerminalSummary[] = [] + const ptyIdsFromLeaves = new Set() + if (graphEpoch !== null) { + for (const leaf of this.leaves.values()) { + if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { + continue + } + if ( + opts.requireFreshPtyLiveness && + (!leaf.ptyId || !refreshedPtyLiveness?.has(leaf.ptyId)) + ) { + continue + } + if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { + continue + } + if (leaf.ptyId) { + ptyIdsFromLeaves.add(leaf.ptyId) + } + terminals.push(this.buildTerminalSummary(leaf, worktreesById, provenLivePtyIds)) + } + } + + // Why: worktree.ps can classify active worktrees from PTY records even when + // the renderer graph is missing a leaf. terminal.list needs the same fallback + // so mobile does not show a false "No terminals" create flow. + for (const pty of this.ptysById.values()) { + if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { + continue + } + if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) { + continue + } + if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { + continue + } + terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) + } + + const requestedHandles = opts.handles ? new Set(opts.handles) : null + const matchingTerminals = requestedHandles + ? terminals.filter((terminal) => requestedHandles.has(terminal.handle)) + : terminals + const listedTerminals = matchingTerminals.slice(0, limit) + // Why: undefined (pre-flag client) must still get layouts; only an explicit + // `false` opts out. + const visualLayouts = + opts.includeVisualLayouts === false + ? [] + : this.buildTerminalVisualLayouts(listedTerminals, worktreesById, targetWorktreeId) + + return { + terminals: listedTerminals, + hostScope: this.buildTerminalListHostScope( + targetWorktreeId, + matchingTerminals, + worktreesById.values(), + controllerInventory?.queriedHostIds ?? new Set() + ), + ...(visualLayouts.length > 0 ? { visualLayouts } : {}), + topologyRevisions: Object.fromEntries( + [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ + worktreeId, + this.getTerminalTopologyRevision(worktreeId) + ]) + ), + totalCount: matchingTerminals.length, + truncated: matchingTerminals.length > limit + } + } + + // A worktree-scoped request answers for one host only, so name the hosts it + // skipped: absence from a scoped listing is not evidence a worker exited. + private buildTerminalListHostScope( + targetWorktreeId: string | null, + terminals: readonly RuntimeTerminalSummary[], + worktrees: Iterable, + queriedHostIds: ReadonlySet + ): RuntimeTerminalListHostScope { + const knownHostIds = this.listKnownExecutionHostIds( + queriedHostIds, + targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID + ) + let resolvedTargetHostId: ExecutionHostId | null = null + for (const worktree of worktrees) { + if (worktree.hostId) { + knownHostIds.add(worktree.hostId) + if (worktree.id === targetWorktreeId) { + resolvedTargetHostId = worktree.hostId + } + } + } + for (const terminal of terminals) { + if (terminal.executionHostId) { + knownHostIds.add(terminal.executionHostId) + } + } + const scopedHostId = targetWorktreeId + ? (resolvedTargetHostId ?? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + if (scopedHostId) { + knownHostIds.add(scopedHostId) + } + const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds + // Paired runtimes own a separate control plane. Mirrored rows are evidence + // for those rows only; this runtime cannot claim their complete inventory. + const coveredHostIds = new Set( + [...candidates].filter( + (hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime' + ) + ) + return { + hostIds: [...coveredHostIds].sort(), + omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort() + } + } + + async inspectTerminalProcessIncarnationLiveness( + processIncarnation: string, + serializedHostScope: string | null + ): Promise<'live' | 'exited' | 'unverifiable'> { + const hostScope = parseWorkerTerminalHostScope(serializedHostScope) + if (!hostScope || !this.ptyController?.listProcesses) { + return 'unverifiable' + } + const listed = await withTimeoutResult( + this.ptyController.listProcesses(hostScope.kind === 'ssh' ? hostScope.targetId : null), + PTY_CONTROLLER_LIST_TIMEOUT_MS + ) + if (!listed.ok) { + return 'unverifiable' + } + return classifyWorkerTerminalProcessIncarnation(processIncarnation, listed.value) + } + + private getTerminalTopologyRevision(worktreeId: string): number { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalTopologyRevisionByRepoId?.[repoId] ?? + this.terminalTopologyRevisionByRepoId.get(repoId) ?? + 0 + ) + } + + async adoptTerminalOrphans( + request: RuntimeTerminalOrphanAdoptionRequest + ): Promise { + if (request.claims.length === 0) { + throw new Error('terminal_orphan_claims_required') + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) + return this.runWorktreeTerminalMutation(workspace.id, async () => { + const resolvedWorkspace = workspace.folderWorkspace + ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) + : await this.resolveWorktreeSelector(`id:${workspace.id}`) + const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + [resolvedWorkspace], + workspace.id, + undefined, + workspace.connectionId ?? null + ) + if (!inventory) { + throw new Error('terminal_liveness_unavailable') + } + return this.adoptTerminalOrphansFromInventoryUnderMutation(request, workspace, inventory) + }) + } + + private async adoptTerminalOrphansFromInventoryUnderMutation( + request: RuntimeTerminalOrphanAdoptionRequest, + workspace: TerminalWorkspaceLaunchScope, + inventory: PtyControllerInventory + ): Promise { + const { livePtyIds, terminalIdentityByPtyId } = inventory + const store = this.store + const session = this.getWorkspaceSessionForWorktree(workspace.id) + if ( + !store?.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) || + !session + ) { + throw new Error('workspace_session_unavailable') + } + const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id) + if (!sessionWorktreeId) { + throw new Error('terminal_orphan_competing_owner') + } + const repoId = getRepoIdFromWorktreeId(workspace.id) + const worktreeConnectionId = workspace.connectionId + let worktreeWslDistro: string | null = null + if (!worktreeConnectionId && workspace.repo) { + try { + worktreeWslDistro = + getLocalProjectWorktreeGitOptions(this.requireStore(), workspace.repo).wslDistro ?? null + } catch { + throw new Error('terminal_orphan_owner_mismatch') + } + } + const currentRevision = this.getTerminalTopologyRevision(workspace.id) + const seenPtyIds = new Set() + const seenPaneKeys = new Set() + const validated = request.claims.map((claim) => { + const paneKey = makePaneKey(claim.tabId, claim.leafId) + if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) { + throw new Error('terminal_orphan_claim_duplicate') + } + seenPtyIds.add(claim.ptyId) + seenPaneKeys.add(paneKey) + const live = this.getLivePtyForHandle(claim.terminal) + const pty = live?.pty + const controllerIdentity = terminalIdentityByPtyId.get(claim.ptyId) + if ( + !pty || + pty.ptyId !== claim.ptyId || + controllerIdentity?.handle !== claim.terminal || + controllerIdentity?.incarnationId !== claim.incarnationId || + !livePtyIds.has(claim.ptyId) || + !pty.connected || + !pty.incarnationId || + pty.incarnationId !== claim.incarnationId + ) { + throw new Error('terminal_orphan_stale') + } + if ( + !worktreeIdsEqual(pty.worktreeId, workspace.id) || + !terminalOrphanExecutionOwnersEqual( + { connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro }, + { + connectionId: pty.connectionId ?? null, + ...(controllerIdentity?.wslDistro !== undefined + ? { wslDistro: controllerIdentity.wslDistro } + : process.platform === 'win32' && !worktreeConnectionId + ? {} + : { wslDistro: null }) + } + ) + ) { + throw new Error('terminal_orphan_owner_mismatch') + } + const visualOwners = this.getLeavesForPty(claim.ptyId) + if ( + visualOwners.some( + (owner) => + !worktreeIdsEqual(owner.worktreeId, workspace.id) || + owner.tabId !== claim.tabId || + owner.leafId !== claim.leafId + ) + ) { + throw new Error('terminal_orphan_already_visual') + } + if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) { + throw new Error('terminal_orphan_competing_owner') + } + return { claim, pty, paneKey } + }) + + const persistedBindingsByPtyId = new Map() + const addPersistedBinding = ( + ptyId: string, + binding: { worktreeId: string; paneKey: string } + ): void => { + const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] + bindings.push(binding) + persistedBindingsByPtyId.set(ptyId, bindings) + } + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { + for (const tab of tabs) { + const layout = session.terminalLayoutsByTabId[tab.id] + for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (boundPtyId) { + addPersistedBinding(boundPtyId, { + worktreeId, + paneKey: makePaneKey(tab.id, leafId) + }) + } + } + if (tab.ptyId && !layout) { + addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id }) + } + } + } + const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => { + const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] + if (bindings.length > 1) { + throw new Error('terminal_orphan_competing_owner') + } + return bindings[0] ?? null + } + const isExactPersisted = validated.every(({ claim, paneKey }) => { + const binding = persistedBinding(claim.ptyId) + return ( + binding !== null && + worktreeIdsEqual(binding.worktreeId, workspace.id) && + binding.paneKey === paneKey && + session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId + ) + }) + if (isExactPersisted && sessionWorktreeId === workspace.id) { + for (const { claim, pty, paneKey } of validated) { + pty.tabId = claim.tabId + pty.paneKey = paneKey + } + return { + adopted: false, + topologyRevision: currentRevision, + snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) + } + } + if (currentRevision !== request.expectedTopologyRevision) { + throw new Error('terminal_topology_conflict') + } + + const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? []) + const topologyGroups = request.topology?.groups ?? [] + if (request.topology) { + const claimedLeafIdsByTabId = new Map>() + for (const { claim } of validated) { + const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set() + leafIds.add(claim.leafId) + claimedLeafIdsByTabId.set(claim.tabId, leafIds) + } + if ( + topologyTabsById.size !== request.topology.tabs.length || + topologyTabsById.size !== claimedLeafIdsByTabId.size + ) { + throw new Error('terminal_orphan_topology_invalid') + } + for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) { + const topologyTab = topologyTabsById.get(tabId) + if (!topologyTab) { + throw new Error('terminal_orphan_topology_invalid') + } + const topologyLeafIds = new Set() + const nodes = [topologyTab.root] + let leafCount = 0 + while (nodes.length > 0) { + const node = nodes.pop()! + if (node.type === 'leaf') { + leafCount += 1 + topologyLeafIds.add(node.leafId) + } else { + nodes.push(node.first, node.second) + } + } + if ( + leafCount !== topologyLeafIds.size || + topologyLeafIds.size !== claimedLeafIds.size || + [...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) || + !topologyLeafIds.has(topologyTab.activeLeafId) || + (topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId)) + ) { + throw new Error('terminal_orphan_topology_invalid') + } + } + const seenGroupIds = new Set() + const groupedTabIds = new Set() + for (const group of topologyGroups) { + if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) { + throw new Error('terminal_orphan_topology_invalid') + } + seenGroupIds.add(group.id) + for (const tabId of group.tabOrder) { + if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) { + throw new Error('terminal_orphan_topology_invalid') + } + groupedTabIds.add(tabId) + } + if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) { + throw new Error('terminal_orphan_topology_invalid') + } + } + if (groupedTabIds.size !== topologyTabsById.size) { + throw new Error('terminal_orphan_topology_invalid') + } + if (request.topology.groupLayout) { + if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) { + throw new Error('terminal_orphan_topology_invalid') + } + } + } + + for (const { claim, paneKey } of validated) { + const existingBinding = persistedBinding(claim.ptyId) + if ( + existingBinding && + (!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) || + existingBinding.paneKey !== paneKey) + ) { + throw new Error('terminal_orphan_competing_owner') + } + const proposedPtyId = + session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId] + if (proposedPtyId && proposedPtyId !== claim.ptyId) { + throw new Error('terminal_orphan_surface_occupied') + } + const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId)) + if ( + graphOwner && + (graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id)) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + if ( + Object.entries(session.tabsByWorktree).some( + ([ownerWorktreeId, tabs]) => + !worktreeIdsEqual(ownerWorktreeId, workspace.id) && + tabs.some((tab) => tab.id === claim.tabId) + ) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + throw new Error('terminal_orphan_surface_retired') + } + for (const snapshot of this.mobileSessionTabsByWorktree.values()) { + const surfaceOwner = snapshot.tabs.find( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && + tab.parentTabId === claim.tabId && + tab.leafId === claim.leafId + ) + if ( + surfaceOwner && + (snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId) + ) { + throw new Error('terminal_orphan_surface_occupied') + } + const owner = snapshot.tabs.find( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.ptyId === claim.ptyId + ) + if ( + owner && + (snapshot.worktree !== workspace.id || + owner.parentTabId !== claim.tabId || + owner.leafId !== claim.leafId) + ) { + throw new Error('terminal_orphan_competing_owner') + } + } + } + + const next = structuredClone(session) + canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, workspace.id) + const existingTabs = next.tabsByWorktree[workspace.id] ?? [] + const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab])) + for (const { claim, pty, paneKey } of validated) { + let tab = tabsById.get(claim.tabId) + if (!tab) { + const title = + getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` + tab = { + id: claim.tabId, + ptyId: claim.ptyId, + worktreeId: workspace.id, + title, + defaultTitle: title, + customTitle: null, + color: null, + sortOrder: tabsById.size, + createdAt: Date.now(), + pendingActivationSpawn: true + } + tabsById.set(claim.tabId, tab) + } + const existingLayout = next.terminalLayoutsByTabId[claim.tabId] + const topologyTab = topologyTabsById.get(claim.tabId) + next.terminalLayoutsByTabId[claim.tabId] = topologyTab + ? { + ...existingLayout, + root: topologyTab.root, + activeLeafId: topologyTab.activeLeafId, + expandedLeafId: topologyTab.expandedLeafId, + ptyIdsByLeafId: { + ...existingLayout?.ptyIdsByLeafId, + [claim.leafId]: claim.ptyId + } + } + : existingLayout + ? { + ...existingLayout, + root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId) + ? existingLayout.root + : existingLayout.root === null + ? { type: 'leaf', leafId: claim.leafId } + : { + type: 'split', + direction: 'vertical', + first: existingLayout.root, + second: { type: 'leaf', leafId: claim.leafId } + }, + ptyIdsByLeafId: { + ...existingLayout.ptyIdsByLeafId, + [claim.leafId]: claim.ptyId + } + } + : { + root: { type: 'leaf', leafId: claim.leafId }, + activeLeafId: claim.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [claim.leafId]: claim.ptyId } + } + next.terminalPtyIncarnationsByPaneKey = { + ...next.terminalPtyIncarnationsByPaneKey, + [paneKey]: claim.incarnationId + } + } + const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))] + next.tabsByWorktree[workspace.id] = [...tabsById.values()] + const activeTabId = + request.activeTabId && tabsById.has(request.activeTabId) + ? request.activeTabId + : (adoptedTabIds[0] ?? null) + const existingGroups = next.tabGroups?.[workspace.id] ?? [] + const targetGroupId = + (request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId) + ? request.activeGroupId + : existingGroups[0]?.id) ?? + request.activeGroupId ?? + randomUUID() + const proposedGroups = topologyGroups.map((group) => ({ + ...group, + worktreeId: workspace.id + })) + const groups = + existingGroups.length === 0 && proposedGroups.length > 0 + ? proposedGroups + : existingGroups.length > 0 + ? existingGroups + .map((group) => { + const proposed = proposedGroups.find((candidate) => candidate.id === group.id) + const tabOrder = proposed + ? [ + ...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)), + ...proposed.tabOrder + ] + : group.id === targetGroupId && proposedGroups.length === 0 + ? [...new Set([...group.tabOrder, ...adoptedTabIds])] + : group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)) + return { + ...group, + tabOrder, + activeTabId: proposed + ? proposed.activeTabId + : group.id === targetGroupId && activeTabId + ? activeTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null), + ...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {}) + } + }) + .concat( + proposedGroups.filter( + (proposed) => !existingGroups.some((group) => group.id === proposed.id) + ) + ) + : [{ id: targetGroupId, worktreeId: workspace.id, activeTabId, tabOrder: adoptedTabIds }] + const retainedGroups = groups.filter((group) => group.tabOrder.length > 0) + next.tabGroups = { + ...next.tabGroups, + [workspace.id]: retainedGroups + } + const mergedGroupLayout = mergeTerminalOrphanGroupLayout({ + existingLayout: next.tabGroupLayouts?.[workspace.id], + existingGroupIds: existingGroups.map((group) => group.id), + proposedLayout: request.topology?.groupLayout, + proposedGroupIds: proposedGroups.map((group) => group.id), + mergedGroupIds: retainedGroups.map((group) => group.id) + }) + if (mergedGroupLayout) { + next.tabGroupLayouts = { + ...next.tabGroupLayouts, + [workspace.id]: mergedGroupLayout + } + } + const activeGroup = + (request.activeGroupId + ? retainedGroups.find( + (group) => + group.id === request.activeGroupId && + (!activeTabId || group.tabOrder.includes(activeTabId)) + ) + : undefined) ?? + retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ?? + retainedGroups[0]! + const convergedActiveTabId = + activeTabId && activeGroup.tabOrder.includes(activeTabId) + ? activeTabId + : activeGroup.activeTabId + next.activeTabIdByWorktree = { + ...next.activeTabIdByWorktree, + ...(convergedActiveTabId ? { [workspace.id]: convergedActiveTabId } : {}) + } + next.activeGroupIdByWorktree = { + ...next.activeGroupIdByWorktree, + [workspace.id]: activeGroup.id + } + const persisted = advanceTerminalTopologyRevision(next, workspace.id) + let staged: WorkspaceSessionState | null = null + try { + this.setWorkspaceSessionForWorktree(workspace.id, persisted) + staged = this.getWorkspaceSessionForWorktree(workspace.id) + await this.flushWorkspaceSessionOrThrowAsync() + } catch (error) { + const current = this.getWorkspaceSessionForWorktree(workspace.id) + if (staged && current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.setWorkspaceSessionForWorktree(workspace.id, rolledBack) + } + } + throw error + } + for (const { claim, pty, paneKey } of validated) { + pty.tabId = claim.tabId + pty.paneKey = paneKey + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(workspace.id, { + force: true, + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + this.notifyMobileSessionTabsChanged(workspace.id) + return { + adopted: true, + topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1, + snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) + } + } + + private getTerminalOrphanAdoptionSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + return this.getMobileSessionTabsForWorktree(worktreeId) + } + + private buildTerminalVisualLayouts( + terminals: RuntimeTerminalSummary[], + worktreesById: Map, + targetWorktreeId: string | null + ): RuntimeTerminalVisualLayout[] { + if (terminals.length === 0) { + return [] + } + // Why: the mobile/session snapshot supplies topology, but terminal.list + // must print the same handles in both the flat list and visual tree. + const summariesByLeafKey = new Map( + terminals.map((terminal) => [this.getLeafKey(terminal.tabId, terminal.leafId), terminal]) + ) + const summariesByWorktree = new Map() + for (const terminal of terminals) { + const existing = summariesByWorktree.get(terminal.worktreeId) + if (existing) { + existing.push(terminal) + } else { + summariesByWorktree.set(terminal.worktreeId, [terminal]) + } + } + const snapshots = targetWorktreeId + ? [this.mobileSessionTabsByWorktree.get(targetWorktreeId)].filter( + (snapshot): snapshot is RuntimeMobileSessionTabsSnapshot => snapshot !== undefined + ) + : [...this.mobileSessionTabsByWorktree.values()] + const layouts: RuntimeTerminalVisualLayout[] = [] + for (const snapshot of snapshots) { + const worktreeTerminals = summariesByWorktree.get(snapshot.worktree) + if (!worktreeTerminals || worktreeTerminals.length === 0) { + continue + } + const groups = this.buildTerminalVisualGroups(snapshot, summariesByLeafKey) + if (groups.length === 0) { + continue + } + const groupsById = new Map( + groups + .filter((group): group is RuntimeTerminalVisualGroupNode & { groupId: string } => + Boolean(group.groupId) + ) + .map((group) => [group.groupId, group]) + ) + const root = + this.buildTerminalVisualGroupLayout(snapshot.tabGroupLayout, groupsById) ?? groups[0] + if (!root) { + continue + } + const worktree = worktreesById.get(snapshot.worktree) + layouts.push({ + worktreeId: snapshot.worktree, + worktreePath: worktree?.path ?? worktreeTerminals[0]?.worktreePath ?? '', + root + }) + } + return layouts + } + + private buildTerminalVisualGroups( + snapshot: RuntimeMobileSessionTabsSnapshot, + summariesByLeafKey: ReadonlyMap + ): RuntimeTerminalVisualGroupNode[] { + const terminalTabs = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + if (terminalTabs.length === 0) { + return [] + } + const tabsByParentId = new Map() + const parentOrder: string[] = [] + for (const tab of terminalTabs) { + const existing = tabsByParentId.get(tab.parentTabId) + if (existing) { + existing.push(tab) + } else { + parentOrder.push(tab.parentTabId) + tabsByParentId.set(tab.parentTabId, [tab]) + } + } + const groupSources = + snapshot.tabGroups && snapshot.tabGroups.length > 0 + ? snapshot.tabGroups + : [{ id: null, activeTabId: snapshot.activeTabId, tabOrder: parentOrder }] + return groupSources + .map((group): RuntimeTerminalVisualGroupNode | null => { + const tabs = group.tabOrder + .map((tabId) => { + const surfaces = + tabsByParentId.get(tabId) ?? terminalTabs.filter((tab) => tab.id === tabId) + return this.buildTerminalVisualTab(tabId, surfaces, summariesByLeafKey) + }) + .filter((tab): tab is RuntimeTerminalVisualTab => tab !== null) + if (tabs.length === 0) { + return null + } + return { + type: 'group', + groupId: group.id, + activeTabId: + group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId) + ? group.activeTabId + : (tabs[0]?.tabId ?? null), + tabs + } + }) + .filter((group): group is RuntimeTerminalVisualGroupNode => group !== null) + } + + private buildTerminalVisualTab( + tabId: string, + surfaces: RuntimeMobileSessionTerminalTab[], + summariesByLeafKey: ReadonlyMap + ): RuntimeTerminalVisualTab | null { + const firstSurface = surfaces[0] + if (!firstSurface) { + return null + } + const parentTabId = firstSurface.parentTabId + const requestedActiveLeafId = + firstSurface.parentLayout?.activeLeafId ?? + surfaces.find((surface) => surface.isActive)?.leafId ?? + firstSurface.leafId + const root = firstSurface.parentLayout?.root ?? { + type: 'leaf' as const, + leafId: firstSurface.leafId + } + const visibleLeafIds = this.collectVisibleTerminalLeafIds(root, parentTabId, summariesByLeafKey) + if (visibleLeafIds.length === 0) { + return null + } + const activeLeafId = + (requestedActiveLeafId && visibleLeafIds.includes(requestedActiveLeafId) + ? requestedActiveLeafId + : surfaces.find((surface) => surface.isActive && visibleLeafIds.includes(surface.leafId)) + ?.leafId) ?? visibleLeafIds[0]! + const panes = this.buildTerminalVisualPane(root, parentTabId, activeLeafId, summariesByLeafKey) + if (!panes) { + return null + } + return { + tabId: parentTabId || tabId, + title: this.tabs.get(parentTabId)?.title ?? firstSurface.title ?? null, + activeLeafId, + panes + } + } + + private collectVisibleTerminalLeafIds( + node: TerminalPaneLayoutNode, + tabId: string, + summariesByLeafKey: ReadonlyMap + ): string[] { + if (node.type === 'leaf') { + return summariesByLeafKey.has(this.getLeafKey(tabId, node.leafId)) ? [node.leafId] : [] + } + return [ + ...this.collectVisibleTerminalLeafIds(node.first, tabId, summariesByLeafKey), + ...this.collectVisibleTerminalLeafIds(node.second, tabId, summariesByLeafKey) + ] + } + + private buildTerminalVisualPane( + node: TerminalPaneLayoutNode, + tabId: string, + activeLeafId: string | null, + summariesByLeafKey: ReadonlyMap + ): RuntimeTerminalVisualPaneNode | null { + if (node.type === 'leaf') { + const summary = summariesByLeafKey.get(this.getLeafKey(tabId, node.leafId)) + if (!summary) { + return null + } + return { + type: 'terminal', + handle: summary.handle, + tabId: summary.tabId, + leafId: summary.leafId, + title: summary.title, + connected: summary.connected, + active: summary.leafId === activeLeafId + } + } + const first = this.buildTerminalVisualPane(node.first, tabId, activeLeafId, summariesByLeafKey) + const second = this.buildTerminalVisualPane( + node.second, + tabId, + activeLeafId, + summariesByLeafKey + ) + if (first && second) { + return { type: 'pane-split', direction: node.direction, first, second } + } + return first ?? second + } + + private buildTerminalVisualGroupLayout( + node: TabGroupLayoutNode | null | undefined, + groupsById: ReadonlyMap + ): RuntimeTerminalVisualLayoutNode | null { + if (!node) { + return null + } + if (node.type === 'leaf') { + return groupsById.get(node.groupId) ?? null + } + const first = this.buildTerminalVisualGroupLayout(node.first, groupsById) + const second = this.buildTerminalVisualGroupLayout(node.second, groupsById) + if (first && second) { + return { type: 'split', direction: node.direction, first, second } + } + return first ?? second + } + + // Why: when --terminal is omitted, the CLI auto-resolves to the active + // terminal in the current worktree — matching browser's implicit active tab. + async resolveActiveTerminal(worktreeSelector?: string): Promise { + if (this.graphStatus !== 'ready') { + const targetWorktreeId = worktreeSelector + ? (await this.resolveWorktreeSelector(worktreeSelector)).id + : null + const snapshots = targetWorktreeId + ? [this.getMobileSessionTabsForWorktree(targetWorktreeId)] + : await this.listAllMobileSessionTabs() + for (const snapshot of snapshots) { + const activeTerminal = snapshot.tabs.find( + (tab) => + tab.type === 'terminal' && + tab.isActive && + tab.status === 'ready' && + typeof tab.terminal === 'string' + ) + if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) { + return activeTerminal.terminal + } + } + const listed = await this.listTerminals(worktreeSelector, undefined, { + includeVisualLayouts: false + }) + const first = listed.terminals[0]?.handle + if (first) { + return first + } + throw new Error('no_active_terminal') + } + this.assertGraphReady() + + const targetWorktreeId = worktreeSelector + ? (await this.resolveWorktreeSelector(worktreeSelector)).id + : null + + // Prefer the tab's activeLeafId — this is the pane the user last focused + for (const tab of this.tabs.values()) { + if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) { + continue + } + if (!tab.activeLeafId) { + continue + } + const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId) + const leaf = this.leaves.get(leafKey) + if (leaf) { + return this.issueHandle(leaf) + } + } + + // Fallback: any leaf in the target worktree + for (const leaf of this.leaves.values()) { + if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { + continue + } + return this.issueHandle(leaf) + } + + throw new Error('no_active_terminal') + } + + // Why: orchestration records the pane key as the remint-stable assignee + // identity at dispatch time; null (best-effort) rather than throwing so + // dispatch still works for handles without a resolvable pane. + getTerminalPaneKey(handle: string): string | null { + return this.getPaneKeyForTerminalHandle(handle) + } + + getLiveTerminalPaneKey(handle: string): string | null { + const runtimePty = this.getLivePtyForHandle(handle) + if (runtimePty) { + return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null + } + try { + const leaf = this.resolveLiveLeafForHandle(handle) + if (!leaf?.ptyId) { + return null + } + const pty = this.ptysById.get(leaf.ptyId) + return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle) + } catch { + return null + } + } + + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null + return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null + } + + /** Read-only context of the worktree the user is focused on, for plugin + * panels (workspace.readContext). Prefers the persisted session focus and + * falls back to the last-focused pane's worktree; null when neither + * resolves so panels degrade instead of erroring. */ + async resolveActiveWorktreeContext(): Promise<{ + worktreeId: string + path: string + branch: string + displayName: string + } | null> { + let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null + if (!worktreeId && this.graphStatus === 'ready') { + for (const tab of this.tabs.values()) { + if (tab.activeLeafId && tab.worktreeId) { + worktreeId = tab.worktreeId + break + } + } + } + if (!worktreeId) { + return null + } + try { + const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) + return { + worktreeId: resolved.id, + path: resolved.git.path, + branch: resolved.git.branch, + displayName: resolved.displayName + } + } catch { + return null + } + } + + getTerminalProcessIncarnation(handle: string): string | null { + const live = this.getLivePtyForHandle(handle) + const record = live?.record ?? this.handles.get(handle) + if (!record?.ptyId) { + return null + } + const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId + if (incarnationId) { + return `${record.ptyId}:${incarnationId}` + } + // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. + return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` + } + + /** + * Records that we lost contact with a PTY's owning host. Callers must never + * read this as an exit: a detached relay PTY is designed to outlive the + * provider that addressed it. + */ + markPtyLivenessUnverifiable(ptyId: string, reason: string): void { + this.rememberPtyLivenessVerdict(ptyId, { status: 'unverifiable', reason }) + } + + markPtyLivenessLive(ptyId: string): void { + this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] }) + } + + /** + * Records that Orca asked this PTY to stop — a close, a stop, a teardown. + * + * Why before the kill and not at the exit: a requested stop can still be + * delivered by the provider's own exit event, which carries a process status + * indistinguishable from a natural finish. The intent is the only thing that + * separates "the operator closed it" from "the agent died", so it is recorded + * where it is known rather than reconstructed afterwards (STA-4603). + */ + markPtyStopRequested(ptyId: string): void { + this.stopRequestedPtyIds.add(ptyId) + } + + isPtyStopRequested(ptyId: string): boolean { + return this.stopRequestedPtyIds.has(ptyId) + } + + /** Null when nothing has been observed either way, so callers keep their own default. */ + getPtyLivenessVerdict(ptyId: string): PtyLivenessVerdict | null { + return this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict ?? null + } + + getTerminalLivenessVerdict(handle: string): PtyLivenessVerdict | null { + const record = this.getLivePtyForHandle(handle)?.record ?? this.handles.get(handle) + return record?.ptyId ? this.getPtyLivenessVerdict(record.ptyId) : null + } + + private rememberPtyLivenessVerdict(ptyId: string, verdict: PtyLivenessVerdict): void { + if (verdict.status === 'exited') { + // An earned death certificate ends the question; nothing left to remember. + this.ptyLivenessVerdictByPtyId.delete(ptyId) + return + } + this.ptyLivenessVerdictByPtyId.delete(ptyId) + this.ptyLivenessObservationSequence += 1 + this.ptyLivenessVerdictByPtyId.set(ptyId, { + verdict, + observedAt: this.ptyLivenessObservationSequence + }) + while (this.ptyLivenessVerdictByPtyId.size > MAX_TRACKED_PTY_LIVENESS_VERDICTS) { + let oldestOrphaned: string | null = null + for (const candidate of this.ptyLivenessVerdictByPtyId.keys()) { + if ( + !this.ptysById.has(candidate) && + !this.handleByPtyId.has(candidate) && + !this.leafExistsForPty(candidate) + ) { + oldestOrphaned = candidate + break + } + } + if (!oldestOrphaned) { + return + } + this.ptyLivenessVerdictByPtyId.delete(oldestOrphaned) + } + } + + private forgetPtyLivenessVerdict(ptyId: string, observedNoLaterThan?: number): void { + const tracked = this.ptyLivenessVerdictByPtyId.get(ptyId) + if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) { + return + } + this.ptyLivenessVerdictByPtyId.delete(ptyId) + } + + getExactWorkerProviderSession( + handle: string, + observedAfter: number + ): ExactWorkerProviderSession | null { + const paneKey = this.getTerminalPaneKey(handle) + const processIncarnation = this.getTerminalProcessIncarnation(handle) + if (!paneKey || !processIncarnation) { + return null + } + let connectionId: string | null | undefined + let launchToken: string | null | undefined + try { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const pty = this.ptysById.get(ptyId) + connectionId = pty?.connectionId ?? null + launchToken = pty?.launchToken ?? null + } catch { + // Exact worker validation rejects this in production; test/legacy providers may not expose PTY metadata. + connectionId = undefined + launchToken = undefined + } + return selectExactWorkerProviderSession({ + paneKey, + processIncarnation, + connectionId, + launchToken, + observedAfter, + statuses: this.getAgentStatusSnapshotFn?.() ?? [] + }) + } + + validateOrchestrationAgentLauncher(agent: TuiAgent): void { + const settings = this.store?.getSettings() + if (!settings) { + throw new Error('runtime_unavailable') + } + if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { + throw new OrchestrationError( + 'agent_unconfigured', + `Agent launcher ${agent} is disabled or unavailable.` + ) + } + } + + resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { + // Why: the renderer context menu only knows the stable pane key; main owns + // the runtime terminal handle that agents and CLI commands can address. + const handle = this.getTerminalHandleForPaneKey(paneKey) + if (!handle) { + throw new Error('terminal_not_found') + } + const record = this.handles.get(handle) + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null + const pty = this.getPtyRecordForPaneKey(paneKey) + const candidateWorktreeIds = [leaf?.worktreeId, pty?.worktreeId].filter( + (worktreeId): worktreeId is string => Boolean(worktreeId) + ) + const worktreeId = candidateWorktreeIds[0] ?? null + if ( + (candidateWorktreeIds.length > 1 && new Set(candidateWorktreeIds).size > 1) || + (expectedWorktreeId && candidateWorktreeIds.some((id) => id !== expectedWorktreeId)) || + (expectedWorktreeId && candidateWorktreeIds.length === 0) + ) { + // Why: pane coordinates restored by a paired client must not cross workspace ownership. + throw new Error('terminal_not_found') + } + return { + handle, + tabId: parsed?.tabId ?? record?.tabId ?? '', + leafId: parsed?.leafId ?? record?.leafId ?? '', + ptyId: record?.ptyId ?? null, + connected: pty?.connected === true, + ...(worktreeId ? { worktreeId } : {}), + ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) + } + } + + async recoverTerminalPane( + paneKey: string, + expectedWorktreeId: string, + expectedHandle?: string + ): Promise { + const parsed = parsePaneKey(paneKey) + const pty = this.getPtyRecordForPaneKey(paneKey) + if ( + !parsed || + !pty || + !expectedHandle || + pty.worktreeId !== expectedWorktreeId || + this.getPaneKeyForTerminalHandle(expectedHandle) !== paneKey + ) { + throw new Error('terminal_not_found') + } + const recoveryKey = `${expectedWorktreeId}\0${paneKey}` + const pending = this.terminalPaneRecoveryByIdentity.get(recoveryKey) + if (pending) { + return pending + } + if (pty?.connected) { + const current = this.resolveTerminalPane(paneKey, expectedWorktreeId) + if (expectedHandle === undefined || current.handle !== expectedHandle) { + return current + } + throw new Error('terminal_not_recoverable') + } + if ( + !this.getRecentExpiredSshLease(expectedWorktreeId, parsed.tabId, parsed.leafId, pty.ptyId) + ) { + // Why: an explicit close leaves a terminated lease; only relay expiry authorizes shell recreation. + throw new Error('terminal_not_recoverable') + } + // Why: disconnected PTYs can reissue handles during graph cleanup; only a connected replacement satisfies the pane CAS. + const recovery = this.createTerminal(`id:${expectedWorktreeId}`, { + tabId: parsed.tabId, + leafId: parsed.leafId, + focus: false + }).then((terminal) => ({ + handle: terminal.handle, + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId: terminal.ptyId ?? null, + worktreeId: expectedWorktreeId + })) + this.terminalPaneRecoveryByIdentity.set(recoveryKey, recovery) + const clearRecovery = (): void => { + if (this.terminalPaneRecoveryByIdentity.get(recoveryKey) === recovery) { + this.terminalPaneRecoveryByIdentity.delete(recoveryKey) + } + } + void recovery.then(clearRecovery, clearRecovery) + return recovery + } + + async showTerminal(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const worktreesById = await this.getResolvedWorktreeMap() + const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById) + const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview) + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + return { + ...summary, + preview, + tabId: pty.pty.tabId ?? pty.record.tabId, + leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId, + paneRuntimeId: -1, + ptyId: pty.pty.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch, + ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) + } + } + const graphEpoch = this.captureReadyGraphEpoch() + const worktreesById = await this.getResolvedWorktreeMap() + this.assertStableReadyGraph(graphEpoch) + const { leaf } = this.getLiveLeafForHandle(handle) + const summary = this.buildTerminalSummary(leaf, worktreesById) + const preview = leaf.ptyId + ? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview) + : summary.preview + this.assertStableReadyGraph(graphEpoch) + if (leaf.ptyId) { + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) + } + return { + ...summary, + preview, + paneRuntimeId: leaf.paneRuntimeId, + ptyId: leaf.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch, + ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) + } + } + + async readTerminal( + handle: string, + opts: { cursor?: number; limit?: number; screen?: boolean } = {}, + providerSnapshot: ProviderSnapshotReadOptions = {} + ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const read = this.readPtyTerminal(handle, pty.pty, opts) + const visibleRead = opts.screen + ? await this.readRenderedScreen(pty.pty.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot) + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + return labelTerminalReadSource(visibleRead) + } + + const { leaf } = this.getLiveLeafForHandle(handle) + const read = readTerminalTail({ + handle, + status: getTerminalState(leaf), + previewLines: leaf.tailBuffer, + completedLines: leaf.tailTranscriptBuffer, + partialLine: leaf.tailPartialLine, + completedLineCount: leaf.tailLinesTotal, + bufferTruncated: leaf.tailTruncated, + cursor: opts.cursor, + limit: opts.limit + }) + if (!leaf.ptyId) { + return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' } + } + const visibleRead = opts.screen + ? await this.readRenderedScreen(leaf.ptyId, read, opts) + : await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot) + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) + return labelTerminalReadSource(visibleRead) + } + + // Why: the default read is the accumulated pty stream, which stacks every repaint of a line + // ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward. + // That is the right answer for "what happened over time" and the wrong one for "what is on + // screen", so an explicit screen read goes to the emulator state instead. When no rendered + // state exists the stream is still returned, but labelled `screen-unavailable` rather than + // passed off as a screen — silently answering the other question is the defect this exists to + // stop, and that label is what separates it from a stream the caller actually asked for. + // A cursor cannot reach here: pairing one with a screen read is refused at the RPC boundary, + // because rendered lines carrying the stream's pagination metadata would mix both frames. + private async readRenderedScreen( + ptyId: string, + read: RuntimeTerminalRead, + opts: { limit?: number } = {} + ): Promise { + const visibleState = await this.readVisibleTerminalState(ptyId) + const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + if (projection.lines.length === 0) { + return { ...read, source: 'screen-unavailable' } + } + return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) + } + + // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent + // verdict per ptyId serves the burst instead of a probe round-trip each call. + private readonly provenAbsentLeafPtyVerdicts = new Map() + private readonly leafPtyAbsenceProbes = new Map>() + private controllerKnowsPtyIsLive(ptyId: string): boolean { + try { + return this.ptyController?.hasPty?.(ptyId) === true + } catch { + // Why: liveness lookup failures are doubt; doubt never gates a write. + return false + } + } + + /** True only on controller-proven absence; live, unknown, and probe errors all answer false. */ + private isLeafPtyProvenAbsent(ptyId: string): Promise { + // Why hasPty and not ptysById: graph sync mirrors a connected record for + // every leaf ptyId — including a prior process's — so runtime records can't + // distinguish live from stale. The controller's exact-id hasPty is the + // provider's own synchronous inventory: a known id is alive, skip probing + // and supersede any cached verdict (the id came back). + if (this.controllerKnowsPtyIsLive(ptyId)) { + this.provenAbsentLeafPtyVerdicts.delete(ptyId) + return Promise.resolve(false) + } + const verdictAt = this.provenAbsentLeafPtyVerdicts.get(ptyId) + if (verdictAt !== undefined) { + if (Date.now() - verdictAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS) { + return Promise.resolve(true) + } + this.provenAbsentLeafPtyVerdicts.delete(ptyId) + } + const probeLiveness = this.ptyController?.probePtyLiveness?.bind(this.ptyController) + if (!probeLiveness) { + return Promise.resolve(false) + } + const inFlight = this.leafPtyAbsenceProbes.get(ptyId) + if (inFlight) { + return inFlight + } + const probe = (async () => { + try { + if ((await probeLiveness(ptyId)) !== false) { + return false + } + this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now()) + return true + } catch { + // Why: a failed probe is unknown, and unknown never rejects a write. + return false + } finally { + this.leafPtyAbsenceProbes.delete(ptyId) + } + })() + this.leafPtyAbsenceProbes.set(ptyId, probe) + return probe + } + + async sendTerminal( + handle: string, + action: { + text?: string + enter?: boolean + interrupt?: boolean + }, + options: { + beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + // Why: the pre-Enter wait now scales with the payload, so an abandoned request must be + // able to stop it instead of writing Enter minutes after the caller gave up. + signal?: AbortSignal + } = {} + ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + await assertTerminalInputWithinLimitWithYield(action.text) + await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + + const { leaf } = this.getLiveLeafForHandle(handle) + if (!leaf.writable || !leaf.ptyId) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + await assertTerminalInputWithinLimitWithYield(action.text) + // Why: leaf.writable mirrors the renderer graph, which can still answer for + // a prior process's ptyId — and provider writes to unknown ids are accepted + // no-ops. Only controller-proven absence rejects; unknown proceeds (a + // restored daemon session takes writes before its pane remounts). + if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { + throw new Error('terminal_not_writable') + } + + await this.writeTerminalAction(leaf.ptyId, action, payload, options) + + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + + async sendTerminalAgentPrompt( + handle: string, + prompt: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + signal?: AbortSignal + } = {} + ): Promise { + const payload = buildAgentPromptPasteBytes(prompt) + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId) + const submits = await this.serializeAgentPromptSubmission( + pty.pty.ptyId, + generation, + async () => { + this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) + this.assertAgentPromptGeneration(pty.pty.ptyId, generation) + return await this.writeTerminalAgentPrompt( + handle, + pty.pty.ptyId, + generation, + payload, + options + ) + } + ) + const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits + return { handle, accepted: true, bytesWritten } + } + + const { leaf } = this.getLiveLeafForHandle(handle) + if (!leaf.writable || !leaf.ptyId) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + // Why: same absence gate as sendTerminal — a stale graph mirror must not + // accept a prompt into a void; unknown liveness still proceeds. + if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { + throw new Error('terminal_not_writable') + } + const generation = this.getPtyLifecycleGeneration(leaf.ptyId) + const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => { + this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!) + this.assertAgentPromptGeneration(leaf.ptyId!, generation) + return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options) + }) + const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits + return { handle, accepted: true, bytesWritten } + } + + async getTerminalAgentStatus(handle: string): Promise { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) + const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + if ( + (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) || + this.hasAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) + ) { + return { handle, isRunningAgent: true, status: 'permission' } + } + if (explicitStatus) { + // Why: permission titles can linger after hooks report the agent resumed. + // Fresh hook state is tighter, but current shell/management evidence wins. + const isRunningAgent = + !terminalTitleBlocksExplicitAgentStatus(terminal.title) && + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { + handle, + isRunningAgent, + status: isRunningAgent ? explicitStatus.status : null + } + } + if (terminal.titleStatus) { + // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, + // not identity, so resolve both through the identity/foreground evidence path. + if ( + isOpenCodeNativeTitle(terminal.title) || + isQuarterCircleSpinnerOnlyAgentTitle(terminal.title) + ) { + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { + handle, + isRunningAgent, + status: isRunningAgent ? terminal.titleStatus : null + } + } + return { handle, isRunningAgent: true, status: terminal.titleStatus } + } + + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } + } + + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + // Why: delayed process evidence belongs only to the PTY that started the + // read, while callers still rely on the established stale-handle contract. + throw new Error('terminal_handle_stale') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string, + waitTextOverride?: string + ): TerminalAgentStatusSnapshot { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } + const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) + const leafTitle = leaf + ? getLatestAgentCandidateTitleInfo( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const ptyTitle = + leafTitle ?? + getLatestAgentCandidateTitleInfo( + { title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt }, + { title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt } + ) + const waitText = + waitTextOverride ?? + buildTerminalWaitText(pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview) + return { + waitText, + waitBlockedAt: pty.pty.waitBlockedAt, + title: ptyTitle?.title ?? null, + titleStatus: ptyTitle + ? detectAgentStatusFromTitle(ptyTitle.title) + : pty.pty.lastAgentStatus, + titleStatusIsLive: ptyTitle !== null + } + } + + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } + const title = getLatestAgentCandidateTitleInfo( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } + ) + return { + waitText: + waitTextOverride ?? + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), + waitBlockedAt: leaf.waitBlockedAt, + title: title?.title ?? null, + titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus, + titleStatusIsLive: (title?.updatedAt ?? 0) > 0 + } + } + + private hasAuthoritativeTerminalWaitPermission( + terminal: TerminalAgentStatusSnapshot, + explicitStatus: { status: AgentStatus; updatedAt: number } | null, + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + ): boolean { + return ( + this.resolveAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) !== null + ) + } + + /** The matched prompt when the tail authoritatively proves a human wait, else null. */ + private resolveAuthoritativeTerminalWaitPermission( + terminal: TerminalAgentStatusSnapshot, + explicitStatus: { status: AgentStatus; updatedAt: number } | null, + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + ): RuntimeTerminalWaitBlockedReason | null { + const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) + if (!blockedByWaitText) { + return null + } + const liveTitleClearsBlockedText = + terminal.titleStatusIsLive && + terminal.titleStatus !== null && + terminal.titleStatus !== 'permission' && + !isOpenCodeNativeTitle(terminal.title) && + // Why exempt: cursor-agent keeps spinning in its title while an approval waits, so a + // "working" title there is not evidence of progress. + blockedByWaitText !== 'agent-approval-prompt' + if (liveTitleClearsBlockedText && lifecycle?.status !== terminal.titleStatus) { + return null + } + // Why no timestamp: the menu is only matched while it still owns the bottom of the screen, + // which is the dating. Requiring one would lose a dialog restored from history, across the + // app restart an unattended run has to survive. + if (blockedByWaitText === 'agent-approval-prompt') { + return blockedByWaitText + } + const newestPermissionAt = Math.max( + explicitStatus?.status === 'permission' ? explicitStatus.updatedAt : -1, + lifecycle?.status === 'permission' ? lifecycle.updatedAt : -1, + terminal.waitBlockedAt ?? -1 + ) + const newestClearAt = Math.max( + explicitStatus && explicitStatus.status !== 'permission' ? explicitStatus.updatedAt : -1, + lifecycle?.status && lifecycle.status !== 'permission' ? lifecycle.updatedAt : -1 + ) + // Equal wall-clock observations fail closed because their raw intra-chunk order is unknown. + return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null + } + + /** Why: "blocked on a human" is the one worker state a coordinator cannot infer — silence + * covers it, a long tool call, and a wedged process alike. `null` means this pane was + * evaluated and nothing proves a wait; `undefined` means it could not be evaluated, which + * is never the same as "not waiting". */ + async getTerminalInteractiveWait( + handle: string + ): Promise { + let ptyId: string + let terminal: TerminalAgentStatusSnapshot + try { + ptyId = this.getTerminalAgentStatusPtyId(handle) + terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + } catch { + return undefined + } + const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) + const promptReason = this.resolveAuthoritativeTerminalWaitPermission( + terminal, + explicitStatus, + this.agentPromptLifecycleByPtyId.get(ptyId) + ) + if (promptReason) { + // A matched prompt is self-proving: it is on this pane's screen now. + return { + source: 'prompt-text', + reason: promptReason, + ...(terminal.waitBlockedAt !== null ? { since: terminal.waitBlockedAt } : {}) + } + } + if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { + return { source: 'title' } + } + if (explicitStatus?.status !== 'permission') { + return null + } + // Why the extra round trip for hook evidence only: a hook row outlives its agent by up to + // AGENT_STATUS_STALE_AFTER_MS, and only the shared verdict proves an agent still owns this + // PTY — a shell that took the pane back rarely leaves a title we can recognize as one. + // Bounded because it reaches a PTY controller that can be a remote host: a wedged probe + // must leave the wait unevaluated, never stall every caller of showTerminal. + const status = await withTimeout( + this.probeAgentStatusOncePerPty(handle, ptyId), + TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS, + undefined + ) + if (!status) { + return undefined + } + return status.isRunningAgent && status.status === 'permission' + ? { source: 'hook', since: explicitStatus.updatedAt } + : null + } + + // Why single-flight: the timeout above abandons the wait, not the request. A wedged remote + // host would otherwise accrue one live probe per poll for as long as a coordinator watches. + private readonly interactiveWaitProbesByPtyId = new Map< + string, + Promise + >() + + private probeAgentStatusOncePerPty( + handle: string, + ptyId: string + ): Promise { + const inFlight = this.interactiveWaitProbesByPtyId.get(ptyId) + if (inFlight) { + return inFlight + } + const probe = this.getTerminalAgentStatus(handle) + .catch(() => undefined) + .finally(() => { + if (this.interactiveWaitProbesByPtyId.get(ptyId) === probe) { + this.interactiveWaitProbesByPtyId.delete(ptyId) + } + }) + this.interactiveWaitProbesByPtyId.set(ptyId, probe) + return probe + } + + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { + if (!this.ptyController) { + return false + } + let foregroundProcess: string | null + try { + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return false + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmationController = this.ptyController + if (!confirmationController?.confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null + } + + private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( + pty: RuntimePtyWorktreeRecord, + title: string + ): boolean { + return ( + !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) + ) + } + + private readPtyForegroundProcessFromController( + ptyId: string, + afterTitleObservation = 0 + ): Promise | null { + const controller = this.ptyController + if (!controller) { + return null + } + const pending = this.ptyForegroundProcessReads.get(ptyId) + if ( + pending?.controller === controller && + pending.startedAfterTitleObservation >= afterTitleObservation + ) { + return pending.promise + } + if (pending?.controller === controller) { + return pending.promise.then( + () => + this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) ?? { + controller, + process: null, + available: false + } + ) + } + const unavailable: PtyForegroundProcessRead = { + controller, + process: null, + available: false + } + let processRead: Promise + try { + processRead = Promise.resolve(controller.getForegroundProcess(ptyId)) + } catch { + const entry: PtyForegroundProcessReadEntry = { + controller, + startedAfterTitleObservation: afterTitleObservation, + promise: Promise.resolve(unavailable) + } + entry.promise = entry.promise.finally(() => { + if (this.ptyForegroundProcessReads.get(ptyId) === entry) { + this.ptyForegroundProcessReads.delete(ptyId) + } + }) + this.ptyForegroundProcessReads.set(ptyId, entry) + return entry.promise + } + let entry: PtyForegroundProcessReadEntry + const promise = processRead + .then((process) => ({ controller, process, available: true })) + .catch(() => unavailable) + .finally(() => { + if (this.ptyForegroundProcessReads.get(ptyId) === entry) { + this.ptyForegroundProcessReads.delete(ptyId) + } + }) + entry = { + controller, + startedAfterTitleObservation: afterTitleObservation, + promise + } + this.ptyForegroundProcessReads.set(ptyId, entry) + return entry.promise + } + + private confirmPtyAgentExit(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + const titleObservedAt = pty?.lastOscTitleAt ?? null + const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) + if (!pty?.connected || !foregroundRead) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + return + } + void foregroundRead.then((result) => { + const current = this.ptysById.get(ptyId) + if (current !== pty || !current.connected) { + return + } + if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { + return + } + if ( + result.controller === this.ptyController && + result.available && + recognizeAgentProcess(result.process) !== null + ) { + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit() + if (restoredStatus !== null && restoredStatus !== undefined) { + current.lastAgentStatus = restoredStatus + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } + return + } + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }) + } + + /** + * Schedules an asynchronous query to check which agent process is currently + * running in the foreground of a PTY. + */ + private refreshPtyForegroundAgent(ptyId: string): void { + void this.refreshPtyForegroundAgentFromController(ptyId) + } + + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (this.mobileSessionTabListeners.size > 0) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + } + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + + /** + * Deduplicates and manages in-flight foreground agent refresh queries + * for a specific PTY. + */ + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 + const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) + if (pendingRefresh) { + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise + } + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController( + ptyId, + entry.startedAfterTitleObservation + ) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } + }) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) + return refresh + } + + /** + * Queries the PTY controller for the active foreground process, identifies if it + * is a recognized agent, and updates the PTY's foreground agent state if changed. + */ + private async loadPtyForegroundAgentFromController( + ptyId: string, + afterTitleObservation = 0 + ): Promise { + if (!this.ptyController) { + return false + } + const pty = this.ptysById.get(ptyId) + if (!pty?.connected) { + return false + } + // Why: foregroundAgent is only consulted as the owner fallback when + // launchAgent is unknown, so a known launchAgent makes the relay + // getForegroundProcess round-trip pure waste (covers all launched agents). + if (pty.launchAgent) { + return false + } + const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) + if (!foregroundRead) { + return false + } + const result = await foregroundRead + if (result.controller !== this.ptyController || !result.available) { + return false + } + const foregroundProcess = result.process + const foregroundAgent = foregroundProcess + ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) + : null + if (pty.foregroundAgent === foregroundAgent) { + return false + } + pty.foregroundAgent = foregroundAgent + this.touchMobileSessionSnapshotsForPty(ptyId) + return true + } + + private getFreshExplicitAgentStatusForHandle( + handle: string, + paneKeyOverride?: string | null + ): { + status: NonNullable + updatedAt: number + /** When this state was entered. Pinned across same-state pings, so it identifies the turn. */ + stateStartedAt: number + } | null { + const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) + const now = Date.now() + let bestStatus: NonNullable | null = null + let bestUpdatedAt = -1 + let bestStateStartedAt = -1 + + const consider = ( + state: AgentStatusEntry['state'] | undefined, + updatedAt: number | null | undefined, + restoredUnconfirmed = false, + stateStartedAt?: number | null + ): void => { + if (!state || restoredUnconfirmed) { + return + } + if (typeof updatedAt !== 'number' || now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return + } + const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) + // Why: older retained permission rows can remain visible after the agent + // resumes. Prefer the newest explicit state; only let permission win ties. + if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { + bestStatus = status + bestUpdatedAt = updatedAt + bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt + } + } + + if (paneKey) { + const retained = this.latestAgentStatusByPaneKey.get(paneKey) + consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) + } + + for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { + if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { + continue + } + consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed, entry.stateStartedAt) + } + + return bestStatus + ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } + : null + } + + private async writeTerminalAction( + ptyId: string, + action: { text?: string; enter?: boolean; interrupt?: boolean }, + payload: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + signal?: AbortSignal + } = {} + ): Promise { + // Why: the lease is checked before the mobile floor is reserved, so a refused send never takes + // a claim it will not use. + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) + // Why: direct terminal.send can carry paste-sized text from RPC/mobile + // clients; chunk text before PTY/ConPTY while preserving suffix separation. + const text = typeof action.text === 'string' ? action.text : '' + const hasSuffix = action.enter || action.interrupt + if (text) { + await this.writeTerminalInputChunks(ptyId, text, options, admitted) + } + if (hasSuffix) { + const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + if (text) { + // Why: same hazard as the agent-prompt path -- Enter must not overtake text the + // execution host is still ingesting, and a flat 500 ms cannot cover 16 MB. + await waitForAgentPromptDelay( + getAgentPromptSubmitDelayMs( + this.getPtyWriteHostPlatform(ptyId), + Buffer.byteLength(text, 'utf8') + ), + options.signal + ) + } + // Why: the 500ms text/suffix pause is long enough for a handoff to complete, so the submit + // is re-checked against the fence the text was admitted under. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + try { + await options.beforeWrite?.(ptyId) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error + } + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + options.reserveWrite?.(ptyId) + const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false + if (!suffixWrote) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } + await options.afterWrite?.(ptyId) + return + } + if (text) { + return + } + + await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + options.reserveWrite?.(ptyId) + const wrote = this.ptyController?.write(ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + await options.afterWrite?.(ptyId) + } + + private async writeTerminalInputChunks( + ptyId: string, + text: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise + } = {}, + admitted: AgentSessionPtyWriteAdmittance + ): Promise { + const chunks = iterateTerminalInputChunks(text) + let chunk = chunks.next() + let firstChunk = true + while (!chunk.done) { + // Why: every inter-chunk yield is a window for a handoff to take the lease; the rest of a + // paste must not land in a session this runtime no longer owns. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false + await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + options.reserveWrite?.(ptyId) + const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + await options.afterWrite?.(ptyId) + chunk = chunks.next() + if (!chunk.done) { + await yieldBetweenTerminalInputChunks() + } + } + } + + /** Platform of the host whose pty transport ingests our writes -- deliberately NOT the OS + * the command runs under. A WSL pane is spawned as `wsl.exe` through the Windows ConPTY + * (see local-pty-provider), so it pays the ConPTY ingest cost even though its shell is + * Linux; an SSH pane is spawned by node-pty on the remote host, so the client's + * process.platform says nothing about it. */ + private getPtyWriteHostPlatform(ptyId: string): NodeJS.Platform { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId + if (!connectionId) { + return process.platform + } + const remotePlatform = getRegisteredSshState(connectionId)?.remotePlatform + if (remotePlatform) { + return remotePlatform + } + // Why: remotePlatform only arrives with the relay handshake; until then the worktree path + // flavor is the same signal getAgentLaunchPlatformForRepo already trusts for a remote repo. + const worktreePath = pty ? splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath : null + return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'linux' + } + + private async writeTerminalAgentPrompt( + handle: string, + ptyId: string, + generation: number, + pastePayload: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + signal?: AbortSignal + } = {} + ): Promise { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) + this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) + // Why: the floor for every wait below. Enter must never overtake bytes the execution + // host is still feeding the child, and that cost is proportional to the payload. + const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) + const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') + const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) + const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) + let wrotePasteBytes = false + let completedPaste = false + try { + const chunks = iterateTerminalInputChunks(pastePayload) + let chunk = chunks.next() + let firstChunk = true + while (!chunk.done) { + const nextChunk = chunks.next() + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + // Why: the first chunk was just admitted above; re-checking the lease there would only + // re-read what `assertAdmitted` established. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false + await options.beforeWrite?.(ptyId) + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + if (nextChunk.done) { + renderGate?.arm() + } + const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + wrotePasteBytes = true + chunk = nextChunk + if (!chunk.done) { + await yieldBetweenTerminalInputChunks() + } + } + completedPaste = true + } catch (error) { + if ( + wrotePasteBytes && + !completedPaste && + this.getPtyLifecycleGeneration(ptyId) === generation + ) { + // Why: a lease that moved mid-paste also refuses this terminator, leaving the TUI in paste + // mode — the incoming owner re-establishes the mode, and feeding a session we no longer own + // is the worse outcome. + try { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + } catch { + // The original refusal is the actionable error. + } + } + renderGate?.dispose() + throw error + } + + if (renderGate) { + try { + await waitForAgentPromptPromise(renderGate.wait(), options.signal) + } finally { + renderGate.dispose() + } + } else { + await waitForAgentPromptDelay( + getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength), + options.signal + ) + } + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + try { + await options.beforeWrite?.(ptyId) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error + } + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + const waitTextCache: AgentPromptWaitTextCache = {} + const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) + this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false + if (!suffixWrote) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } + await verifyAgentPromptSubmission({ + baseline, + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), + timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), + signal: options.signal + }) + return 1 + } + + private async serializeAgentPromptSubmission( + ptyId: string, + generation: number, + submit: () => Promise + ): Promise { + const queueKey = `${ptyId}\u0000${generation}` + const previous = this.agentPromptSubmissionTailByPtyId.get(queueKey) ?? Promise.resolve() + const submission = previous.catch(() => undefined).then(submit) + const tail = submission.then( + () => undefined, + () => undefined + ) + this.agentPromptSubmissionTailByPtyId.set(queueKey, tail) + try { + return await submission + } finally { + if (this.agentPromptSubmissionTailByPtyId.get(queueKey) === tail) { + this.agentPromptSubmissionTailByPtyId.delete(queueKey) + } + } + } + + private getAgentPromptActivity( + handle: string, + ptyId: string, + waitTextCache?: AgentPromptWaitTextCache + ): AgentPromptActivity { + this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + const outputSequence = this.getPtyOutputSequence(ptyId) + const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle) + const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) + const explicit = + explicitCandidate && + (explicitFloor === undefined || explicitCandidate.updatedAt > explicitFloor) + ? explicitCandidate + : null + const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + const ptyStatus = + lifecycle || explicitFloor === undefined + ? (this.ptysById.get(ptyId)?.lastAgentStatus ?? null) + : null + const lifecycleIsNewer = + lifecycle && + (!explicit || + lifecycle.updatedAt > explicit.updatedAt || + (lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission')) + const waitText = waitTextCache + ? readAgentPromptWaitText( + waitTextCache, + outputSequence, + () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText + ) + : undefined + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) + const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) + ? 'permission' + : lifecycleIsNewer + ? lifecycle.status + : (explicit?.status ?? ptyStatus ?? null) + return { + generation: this.getPtyLifecycleGeneration(ptyId), + permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, + workingSequence: lifecycle?.workingSequence ?? 0, + // Why: hook status is the only turn-start signal agents without title coverage have, and it + // reaches here without the window-gated synthetic title frame (#16095). Anchored on + // stateStartedAt, not updatedAt — same-state tool/prompt pings refresh updatedAt and would + // otherwise pass off an in-progress turn as a new one. + explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, + outputSequence, + status + } + } + + private getPtyAgent(ptyId: string): TuiAgent | null { + const pty = this.ptysById.get(ptyId) + return pty?.launchAgent ?? pty?.foregroundAgent ?? null + } + + private assertAgentPromptPermissionSafe( + baseline: AgentPromptActivity, + current: AgentPromptActivity + ): void { + if ( + current.status === 'permission' || + current.permissionSequence > baseline.permissionSequence + ) { + throw new Error('agent_prompt_blocked') + } + } + + private assertAgentPromptGeneration(ptyId: string, expected: number): void { + if (this.getPtyLifecycleGeneration(ptyId) !== expected) { + throw new Error('terminal_handle_stale') + } + } + + /** `pasteIngestMs` is the payload's ingest bound on the executing host. Nothing here may + * settle before it elapses: the agent can repaint mid-ingest, so marker-then-quiet alone + * would fire Enter into a paste ConPTY is still feeding. */ + private createAgentPromptRenderGate( + ptyId: string, + pasteIngestMs: number + ): { + arm: () => void + wait: () => Promise + dispose: () => void + } | null { + if (!isTerminalSendSettlementAgent(this.getPtyAgent(ptyId))) { + return null + } + let armed = false + let canSettle = false + let settled = false + let ingested = pasteIngestMs <= 0 + // Why absolute: the ingest clock starts once, here, but the cap is armed twice (at arm() + // and again on the marker). Re-adding the whole window would charge ingest twice. + const ingestDeadlineAt = Date.now() + pasteIngestMs + let markerCarry = '' + let quietTimer: NodeJS.Timeout | null = null + let hardTimer: NodeJS.Timeout | null = null + let ingestTimer: NodeJS.Timeout | null = null + let resolveRender!: () => void + const rendered = new Promise((resolve) => { + resolveRender = resolve + }) + + const clearGateTimers = (): void => { + if (quietTimer) { + clearTimeout(quietTimer) + quietTimer = null + } + if (hardTimer) { + clearTimeout(hardTimer) + hardTimer = null + } + if (ingestTimer) { + clearTimeout(ingestTimer) + ingestTimer = null + } + } + const finish = (): void => { + if (settled) { + return + } + settled = true + clearGateTimers() + resolveRender() + } + const armQuietTimer = (): void => { + // Why: the quiet window measures the agent going still after a *complete* paste. + // Silence during ingest is not settlement, so it cannot start the clock. + if (!ingested) { + return + } + if (quietTimer) { + clearTimeout(quietTimer) + } + quietTimer = setTimeout(finish, AGENT_PROMPT_RENDER_QUIET_MS) + } + const armHardTimer = (): void => { + if (hardTimer) { + clearTimeout(hardTimer) + } + // Why: the cap bounds the wait *after* the bytes land; a flat 8000 ms would expire + // mid-paste past ~770 KB on Windows and write Enter into it. + hardTimer = setTimeout( + finish, + AGENT_PROMPT_RENDER_TIMEOUT_MS + Math.max(0, ingestDeadlineAt - Date.now()) + ) + } + if (!ingested) { + ingestTimer = setTimeout(() => { + ingestTimer = null + ingested = true + if (canSettle) { + armQuietTimer() + } + }, pasteIngestMs) + } + const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => { + if (!armed || settled) { + return + } + if (!canSettle) { + const combined = markerCarry + data + markerCarry = combined.slice(-(AGENT_PROMPT_RENDER_MARKER.length - 1)) + if (!combined.includes(AGENT_PROMPT_RENDER_MARKER)) { + return + } + canSettle = true + // Why: a slow initial redraw must still receive the full settlement window. + armHardTimer() + } + armQuietTimer() + }) + return { + arm: () => { + armed = true + markerCarry = '' + armHardTimer() + }, + wait: async () => { + if (settled) { + return + } + await rendered + }, + dispose: () => { + unsubscribe() + clearGateTimers() + } + } + } + + async waitForTerminal( + handle: string, + options?: { + condition?: RuntimeTerminalWaitCondition + timeoutMs?: number + signal?: AbortSignal + } + ): Promise { + const condition = options?.condition ?? 'exit' + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (condition === 'exit' && !pty.pty.connected) { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + const ptyWaitText = buildTerminalWaitText( + pty.pty.tailBuffer, + pty.pty.tailPartialLine, + pty.pty.preview + ) + const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText) + if (condition === 'tui-idle' && ptyBlockedReason) { + return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) + } + if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + if ( + condition === 'tui-idle' && + (this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || + isKnownReadyPromptPreview(ptyWaitText)) + ) { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + return await new Promise((resolve, reject) => { + const effectiveTimeoutMs = + typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 + ? options.timeoutMs + : condition === 'tui-idle' + ? TUI_IDLE_DEFAULT_TIMEOUT_MS + : 0 + const waiter: TerminalWaiter = { + handle, + condition, + resolve, + reject, + timeout: null, + pollInterval: null, + abortCleanup: null + } + if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { + reject(new Error('request_aborted')) + return + } + if (effectiveTimeoutMs > 0) { + waiter.timeout = setTimeout(() => { + this.removeWaiter(waiter) + reject(new Error('timeout')) + }, effectiveTimeoutMs) + } + let waiters = this.waitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.waitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + const live = this.getLivePtyForHandle(handle) + if (!live) { + this.removeWaiter(waiter) + reject(new Error('terminal_handle_stale')) + } else if (condition === 'exit' && !live.pty.connected) { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if (condition === 'tui-idle') { + const livePtyWaitText = buildTerminalWaitText( + live.pty.tailBuffer, + live.pty.tailPartialLine, + live.pty.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText) + if (blockedReason) { + this.resolveWaiter( + waiter, + buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) + ) + } else if (live.pty.lastAgentStatus === 'idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if ( + this.getAdoptedPtyExplicitIdleStatus(live.pty) === 'idle' || + isKnownReadyPromptPreview(livePtyWaitText) + ) { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else { + this.startPtyTuiIdleFallbackPoll(waiter, live.pty, effectiveTimeoutMs) + } + } + }) + } + const { leaf } = this.getLiveLeafForHandle(handle) + + if (condition === 'exit' && getTerminalState(leaf) === 'exited') { + return buildTerminalWaitResult(handle, condition, leaf) + } + + const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText) + if (condition === 'tui-idle' && leafBlockedReason) { + return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason) + } + + // Why: if the agent already transitioned to idle (or permission) before the + // waiter was registered, resolve immediately. This uses the same OSC title + // detection that powers the renderer's "Task complete" notifications. + // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the + // agent is blocked on user approval, not finished with its task. + if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { + return buildTerminalWaitResult(handle, condition, leaf) + } + if (condition === 'tui-idle') { + const fastPathTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if ( + (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || + isKnownReadyPromptPreview(leafWaitText) + ) { + return buildTerminalWaitResult(handle, condition, leaf) + } + } + + return await new Promise((resolve, reject) => { + // Why: tui-idle depends on OSC title transitions from a recognized agent. + // If no agent is detected, the waiter would hang forever. Enforce a default + // timeout so unsupported CLIs fail predictably instead of silently blocking. + const effectiveTimeoutMs = + typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 + ? options.timeoutMs + : condition === 'tui-idle' + ? TUI_IDLE_DEFAULT_TIMEOUT_MS + : 0 + + const waiter: TerminalWaiter = { + handle, + condition, + resolve, + reject, + timeout: null, + pollInterval: null, + abortCleanup: null + } + + if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { + reject(new Error('request_aborted')) + return + } + + if (effectiveTimeoutMs > 0) { + waiter.timeout = setTimeout(() => { + this.removeWaiter(waiter) + reject(new Error('timeout')) + }, effectiveTimeoutMs) + } + + let waiters = this.waitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.waitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + + // Why: the handle may go stale or exit in the small gap between the first + // validation and waiter registration. Re-checking here keeps wait --for + // exit honest instead of hanging on a terminal that already changed. + try { + const live = this.getLiveLeafForHandle(handle) + if (getTerminalState(live.leaf) === 'exited') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else if (condition === 'tui-idle') { + const liveLeafWaitText = buildTerminalWaitText( + live.leaf.tailBuffer, + live.leaf.tailPartialLine, + live.leaf.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText) + if (blockedReason) { + this.resolveWaiter( + waiter, + buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) + ) + } else if (live.leaf.lastAgentStatus === 'idle') { + // Why: don't clear lastAgentStatus here. It's a factual record of the + // last detected OSC state, not a one-shot signal. Clearing it causes + // subsequent tui-idle waiters to hang even though the agent is idle — + // the first waiter consumes the status and all later ones see null. + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else { + // Why: renderer-synced previews can show a known ready prompt even + // while the last OSC title is still "working"; keep polling the + // preview/title until the waiter resolves or hits its timeout. + const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title + if ( + (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || + isKnownReadyPromptPreview(liveLeafWaitText) + ) { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else { + this.startTuiIdleFallbackPoll(waiter, live.leaf, effectiveTimeoutMs) + } + } + } + } catch (error) { + this.removeWaiter(waiter) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + subscribeToPtyExit(ptyId: string, listener: () => void): () => void { + const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) + if (this.isPtyKnownExited(ptyId)) { + listener() + return () => {} + } + let listeners = this.ptyExitListenersByPtyId.get(ptyId) + if (!listeners) { + listeners = new Set() + this.ptyExitListenersByPtyId.set(ptyId, listeners) + } + let active = true + const unsubscribe = (): void => { + if (!active) { + return + } + active = false + listeners.delete(listener) + if (listeners.size === 0 && this.ptyExitListenersByPtyId.get(ptyId) === listeners) { + this.ptyExitListenersByPtyId.delete(ptyId) + } + } + listeners.add(listener) + if ( + this.getPtyLifecycleGeneration(ptyId) !== lifecycleGeneration || + this.isPtyKnownExited(ptyId) + ) { + unsubscribe() + listener() + } + return unsubscribe + } + + async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> { + const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId + if (!ptyId) { + throw new Error('terminal_handle_stale') + } + const completionToken = this.setupCompletionTokenByPtyId.get(ptyId) + const exitAbort = new AbortController() + return await new Promise<{ exitCode: number | null }>((resolve, reject) => { + let settled = false + let unsubscribe: (() => void) | null = null + const cleanup = (): void => { + unsubscribe?.() + exitAbort.abort() + } + const finish = (exitCode: number | null): void => { + if (settled) { + return + } + settled = true + cleanup() + this.setupCompletionTokenByPtyId.delete(ptyId) + resolve({ exitCode }) + } + const fail = (error: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const scanner = completionToken ? createSetupCompletionScanner(completionToken, finish) : null + + if (scanner) { + unsubscribe = this.subscribeToTerminalData(ptyId, scanner.scan) + } + // Why: setup can finish before the observer is registered on fast local worktrees. + const replay = this.recentPtyOutputById.get(ptyId)?.read() + if (scanner && replay) { + scanner.scan(replay) + } + if (!settled) { + void this.waitForTerminal(handle, { + condition: 'exit', + signal: exitAbort.signal + }) + .then((wait) => { + if (wait.satisfied && wait.condition === 'exit' && wait.status === 'exited') { + finish(wait.exitCode) + } + }) + .catch(fail) + } + }) + } + + async getWorktreePs( + limit = DEFAULT_WORKTREE_PS_LIMIT, + sourceDefaultsSupported = true, + opts?: { deadlineMs?: number; signal?: AbortSignal } + ): Promise<{ + worktrees: RuntimeWorktreePsSummary[] + totalCount: number + truncated: boolean + }> { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() + const settings = this.store?.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings?.worktreeVisibilityDefaults + : settings?.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = settings + ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } + : undefined + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolvedWorktreeSnapshot.worktrees, + visibilityDefaults + ) + const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => + this.isRuntimeWorktreeVisible( + worktree, + visibilitySourceMatchersByRepoId.get(worktree.repoId), + visibilitySettings + ) + ) + // Why: worktree.ps backs the mobile sidebar, so it must use the same + // host-owned imported-worktree visibility gate as worktree.list/desktop. + const freshPtyLiveness = this.ptyLivenessRefreshRequired + ? await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + null, + opts?.deadlineMs, + opts?.signal + ) + : null + const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) + const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId + const summaries = new Map() + const workingTerminalEvidenceByWorktreeId = new Map() + + // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), + // matching how the renderer's fetchPRForBranch stores entries. We look up cached + // PR info so mobile clients can group worktrees by PR state without making + // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. + const ghCache = this.store?.getGitHubCache?.() + for (const worktree of resolvedWorktrees) { + const meta = + this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] + const repo = repoById.get(worktree.repoId) + let linkedPR: { number: number; state: string } | null = null + const branch = worktree.branch.replace(/^refs\/heads\//, '') + if (branch && ghCache) { + // Why: the renderer keys the PR cache by `repoId::branch` (getGitHubPRCacheKey + // prefers repo.id over repo.path), so read by id first and fall back to path + // for legacy/path-keyed entries. Reading only by path missed every cached + // entry, leaving mobile's linked-PR badge stuck on the 'unknown' fallback. + const cached = + (repo?.id ? ghCache.pr[`${repo.id}::${branch}`] : undefined) ?? + (repo?.path ? ghCache.pr[`${repo.path}::${branch}`] : undefined) + if (cached?.data) { + linkedPR = { number: cached.data.number, state: cached.data.state } + } + } + if (!linkedPR && meta?.linkedPR != null) { + linkedPR = { number: meta.linkedPR, state: 'unknown' } + } + const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform + // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, + // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, + // so a stale same-path entry would nest replacement checkouts under old parents. + const lineage = worktree.lineage + summaries.set(worktree.id, { + // Why: mobile mirrors desktop workspace grouping/order from persisted + // metadata, while older runtimes may not have hydrated every field yet. + workspaceKind: 'git', + worktreeId: worktree.id, + repoId: worktree.repoId, + ...((meta?.hostId ?? worktree.hostId) ? { hostId: meta?.hostId ?? worktree.hostId } : {}), + terminalPlatform, + repo: repo?.displayName ?? worktree.repoId, + path: worktree.path, + branch: worktree.branch, + isArchived: worktree.isArchived, + isMainWorktree: worktree.isMainWorktree, + hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), + ...(lineage?.worktreeInstanceId !== undefined + ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } + : {}), + ...(lineage?.parentWorktreeInstanceId !== undefined + ? { parentWorktreeInstanceId: lineage.parentWorktreeInstanceId } + : {}), + parentWorktreeId: worktree.parentWorktreeId, + childWorktreeIds: worktree.childWorktreeIds, + displayName: worktree.displayName, + workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + sortOrder: meta?.sortOrder ?? 0, + ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), + linkedIssue: worktree.linkedIssue, + linkedPR, + linkedLinearIssue: meta?.linkedLinearIssue ?? null, + linkedGitLabMR: meta?.linkedGitLabMR ?? null, + linkedGitLabIssue: meta?.linkedGitLabIssue ?? null, + comment: meta?.comment ?? '', + isPinned: meta?.isPinned ?? false, + isActive: false, + unread: meta?.isUnread ?? false, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '', + status: 'inactive', + agents: [] + }) + } + + const projectGroupById = new Map( + (this.store?.getProjectGroups?.() ?? []).map((group) => [group.id, group]) + ) + for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { + const projectGroup = projectGroupById.get(folderWorkspace.projectGroupId) + if (!projectGroup?.parentPath) { + continue + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + summaries.set(worktree.id, { + // Why: folder workspaces use the same mobile grouping/order contract as + // git worktrees, but legacy records may be missing order metadata. + workspaceKind: 'folder-workspace', + worktreeId: worktree.id, + repoId: worktree.repoId, + repo: projectGroup.name, + path: worktree.path, + branch: worktree.branch, + isArchived: worktree.isArchived, + isMainWorktree: worktree.isMainWorktree, + hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), + parentWorktreeId: null, + childWorktreeIds: [], + displayName: worktree.displayName, + workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + sortOrder: worktree.sortOrder ?? 0, + ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), + linkedIssue: worktree.linkedIssue ?? null, + linkedPR: null, + linkedLinearIssue: worktree.linkedLinearIssue ?? null, + linkedGitLabMR: worktree.linkedGitLabMR ?? null, + linkedGitLabIssue: worktree.linkedGitLabIssue ?? null, + comment: worktree.comment, + isPinned: worktree.isPinned, + isActive: false, + unread: worktree.isUnread, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '', + status: 'inactive', + agents: [] + }) + } + + const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex( + summaries, + resolvedWorktrees, + platformByRepoId + ) + const missingRuntimeWorktreeIds = new Set() + const countedPtyIds = new Set() + const session = this.store?.getWorkspaceSession?.() + const savedTabOwnerById = new Map() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + savedTabOwnerById.set(tab.id, { worktreeId, title: tab.title }) + } + } + const savedLayoutTabIdByPtyId = new Map() + for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { + for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { + if (ptyId) { + savedLayoutTabIdByPtyId.set(ptyId, tabId) + } + } + } + for (const leaf of this.leaves.values()) { + if ( + !leaf.ptyId || + !leaf.connected || + (freshPtyLiveness !== null && !freshPtyLiveness.has(leaf.ptyId)) + ) { + continue + } + const freshPtyOwner = this.ptysById.get(leaf.ptyId) + if ( + freshPtyLiveness !== null && + freshPtyOwner?.connected && + !worktreeIdsEqual(freshPtyOwner.worktreeId, leaf.worktreeId) + ) { + // Why: provider/persisted ownership is fresher than a renderer leaf left behind by graph migration or another client. + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + leaf.worktreeId + ) + if (!summary) { + continue + } + countedPtyIds.add(leaf.ptyId) + summary.hasHostSidebarActivity = true + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = true + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) + const leafStatus = getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + if (leafStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: this.makeRuntimePaneKey(leaf), + ptyId: leaf.ptyId, + tabId: leaf.tabId + }) + } + mergeWorktreeSummaryStatus(summary, leafStatus) + if ( + leaf.preview && + (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = leaf.preview + } + } + + for (const pty of this.ptysById.values()) { + if ( + !pty.connected || + countedPtyIds.has(pty.ptyId) || + (freshPtyLiveness !== null && !freshPtyLiveness.has(pty.ptyId)) + ) { + continue + } + const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId) + let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined + if (freshPtyLiveness !== null) { + // Why: refresh resolved provider/migration ownership; stale persisted tabs may supply a title but cannot reassign a live PTY. + owner = { + worktreeId: pty.worktreeId, + title: owner?.title ?? getLatestPtyTitle(pty) ?? '' + } + } + if (!owner && persistedTabId && pty.tabId === persistedTabId) { + owner = { + worktreeId: pty.worktreeId, + title: getLatestPtyTitle(pty) ?? '' + } + } + const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') + const hasExplicitRuntimeOwner = + pty.tabId !== null && parsedPaneKey?.tabId === pty.tabId && parsedPaneKey.leafId.length > 0 + const savedTabOwner = pty.tabId ? savedTabOwnerById.get(pty.tabId) : undefined + const hasSavedLayout = + pty.tabId !== null && Object.hasOwn(session?.terminalLayoutsByTabId ?? {}, pty.tabId) + if (!owner && hasExplicitRuntimeOwner && !hasSavedLayout) { + owner = { + worktreeId: savedTabOwner?.worktreeId ?? pty.worktreeId, + title: savedTabOwner?.title ?? getLatestPtyTitle(pty) ?? '' + } + } + if (!owner) { + // Why: provider existence alone cannot attribute a reused or unbound PTY to a workspace. + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + owner.worktreeId + ) + if (!summary) { + continue + } + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = true + summary.hasHostSidebarActivity = true + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) + const ptyStatus = getSavedTabWorktreeStatus(owner.title, true) + if (ptyStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: pty.paneKey, + ptyId: pty.ptyId, + tabId: pty.tabId ?? persistedTabId ?? null + }) + } + mergeWorktreeSummaryStatus(summary, ptyStatus) + if ( + pty.preview && + (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = pty.preview + } + } + + const mirroredWorktreeIdByTabId = new Map() + const sessionsByHostId = new Map() + for (const summary of summaries.values()) { + const repo = repoById.get(summary.repoId) + const hostId = repo ? getRepoExecutionHostId(repo) : 'local' + const session = this.store?.getWorkspaceSession?.(hostId) + if (session) { + sessionsByHostId.set(hostId, session) + } + } + for (const session of sessionsByHostId.values()) { + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + mirroredWorktreeIdByTabId.set(tab.id, worktreeId) + } + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (!summary) { + continue + } + if (tabs.some((tab) => tab.ptyId !== null && this.ptysById.get(tab.ptyId)?.connected)) { + summary.hasHostSidebarActivity = true + } + } + for (const [worktreeId, tabs] of Object.entries(session.browserTabsByWorktree ?? {})) { + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (summary) { + summary.hasHostSidebarActivity = true + } + } + if (session.activeWorktreeId) { + const activeSummary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + session.activeWorktreeId + ) + if (activeSummary) { + activeSummary.isActive = true + } + } + } + // Why: a live renderer graph may precede persistence, but persisted tab + // ownership wins when an automatic workspace rename has already rekeyed it. + for (const [tabId, tab] of this.tabs) { + if (!mirroredWorktreeIdByTabId.has(tabId)) { + mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId) + } + } + + // Why: a connected PTY proves a pane is still live even when its tab has + // already left every session record (daemon-held terminals, graph gaps). + // Deliberately trusts the optimistic connected flag (no freshPtyLiveness + // gate, unlike the count loops above): evidence only KEEPS rows. + const connectedPtyEvidence = { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } + for (const pty of this.ptysById.values()) { + if (!pty.connected) { + continue + } + connectedPtyEvidence.ptyIds.add(pty.ptyId) + if (pty.tabId) { + connectedPtyEvidence.tabIds.add(pty.tabId) + } + if (pty.paneKey) { + connectedPtyEvidence.paneKeys.add(pty.paneKey) + } + } + + this.attachAgentRowsToSummaries( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + mirroredWorktreeIdByTabId, + connectedPtyEvidence, + workingTerminalEvidenceByWorktreeId + ) + + const sorted = [...summaries.values()].sort(compareWorktreePs) + return { + worktrees: sorted.slice(0, limit), + totalCount: sorted.length, + truncated: sorted.length > limit + } + } + + // Why: maps the retained per-pane agent snapshots into each worktree's inline + // agent list, mirroring the desktop sidebar. Lineage parent is resolved from + // the orchestration db (paneKey-keyed), not the OSC payload, since spawn + // hierarchy is pane-level state tracked separately from terminal output. + private attachAgentRowsToSummaries( + summaries: Map, + runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds: Set, + mirroredWorktreeIdByTabId: ReadonlyMap, + connectedPtyEvidence: { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet + }, + workingTerminalEvidenceByWorktreeId: ReadonlyMap< + string, + readonly RuntimeWorkingTerminalEvidence[] + > + ): void { + // Why: most agents report via hooks (agent-hooks/server), not OSC, so the + // hook snapshot is the primary source — same one the desktop sidebar reads. + // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. + const rowSources = new Map() + const now = Date.now() + for (const snapshot of this.latestAgentStatusByPaneKey.values()) { + const { payload } = snapshot + rowSources.set(snapshot.paneKey, { + paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, + tabId: snapshot.tabId, + worktreeId: snapshot.worktreeId, + connectionId: snapshot.connectionId, + payload, + state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), + agentType: payload.agentType ?? null, + prompt: payload.prompt, + lastAssistantMessage: payload.lastAssistantMessage ?? null, + toolName: payload.toolName ?? null, + toolInput: payload.toolInput ?? null, + interrupted: payload.interrupted ?? false, + stateStartedAt: snapshot.stateStartedAt, + updatedAt: snapshot.updatedAt + }) + } + for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { + // Why: old mobile clients ignore this provenance bit, so publishing the row would turn an explicitly unconfirmed restore into fresh activity under version skew. + if (entry.restoredUnconfirmed === true) { + continue + } + const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) + // Why: hook rows win ties, but an older cached hook must not replace a + // fresh OSC status and make a running mobile workspace look inactive. + if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + // restoredUnconfirmed rows already `continue` above. + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + // Why: older OSC reporters cannot express hook-authoritative monitoring mode. + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } + continue + } + rowSources.set(entry.paneKey, { + paneKey: entry.paneKey, + // Hook payloads carry no ptyId; keep the OSC-observed one so the + // connected-PTY rescue survives a hook row winning the freshness race. + ptyId: existing?.ptyId, + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + payload: hookPayload, + state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), + agentType: entry.agentType ?? null, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interrupted: entry.interrupted ?? false, + stateStartedAt: entry.stateStartedAt, + updatedAt: entry.receivedAt + }) + } + if (rowSources.size === 0) { + return + } + const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() + const rowsByWorktree = new Map() + for (const src of rowSources.values()) { + // Why: hooks retain launch-time attribution across automatic workspace + // renames; the tab's current mirrored owner is authoritative when present. + // Legacy numeric pane keys (non-UUID leaves) still name a real tab, so + // parse them too — otherwise their rows would bypass the stale filter. + const tabId = + src.tabId ?? + parsePaneKey(src.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(src.paneKey)?.tabId + const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined + if ( + tabId !== undefined && + mirroredWorktreeId === undefined && + (src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) && + !connectedPtyEvidence.tabIds.has(tabId) && + !connectedPtyEvidence.paneKeys.has(src.paneKey) && + (src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId)) + ) { + // Why: hook snapshots hydrate from last-status.json for days, so a row + // from a local or WSL-relayed pane whose tab left every session and the + // live graph, with no connected PTY, is retained history — surfacing it + // resurrects closed agents on mobile (#6072). SSH rows are exempt (their + // tabs may exist only remotely), as are rows with no resolvable tabId + // (staleness unprovable). Session tabs count as existence: headless + // serve has no renderer graph, and session.tabs.list serves them. + continue + } + const worktreeId = mirroredWorktreeId ?? src.worktreeId + if (!worktreeId) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (!summary) { + continue + } + const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null + const displayName = orchestrationByPaneKey?.[src.paneKey]?.displayName ?? null + const row: RuntimeWorktreeAgentRow = { + paneKey: src.paneKey, + parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, + state: src.state, + ...(src.workingMode ? { workingMode: src.workingMode } : {}), + agentType: src.agentType, + prompt: src.prompt, + taskTitle, + displayName, + lastAssistantMessage: src.lastAssistantMessage, + toolName: src.toolName, + toolInput: src.toolInput, + interrupted: src.interrupted, + stateStartedAt: src.stateStartedAt, + updatedAt: src.updatedAt + } + // Why: SSH/runtime projections can spell an equivalent path differently; + // bucket by the canonical summary id so mobile keeps the agent activity. + const rows = rowsByWorktree.get(summary.worktreeId) + if (rows) { + rows.push(row) + } else { + rowsByWorktree.set(summary.worktreeId, [row]) + } + } + for (const [worktreeId, rows] of rowsByWorktree) { + // Oldest-started first, matching the desktop dashboard's start-order sort. + rows.sort((a, b) => a.stateStartedAt - b.stateStartedAt) + const summary = summaries.get(worktreeId) + if (summary) { + summary.agents = rows + let hasForegroundWorkingAgent = false + const monitoringSources: RuntimeWorktreeAgentSource[] = [] + for (const row of rows) { + if (!isFreshNonDoneAgentStatus(row, now)) { + continue + } + // Why: worktree.ps is mobile's host-sidebar parity source, so a live + // agent must survive the same temporary PTY gaps as desktop. + summary.hasHostSidebarActivity = true + if (row.state === 'working') { + if (row.workingMode === 'monitoring') { + const source = rowSources.get(row.paneKey) + if (source) { + monitoringSources.push(source) + } + } else { + hasForegroundWorkingAgent = true + } + } else { + mergeWorktreeSummaryStatus(summary, 'permission') + } + } + if (hasForegroundWorkingAgent || monitoringSources.length > 0) { + const hasIndependentWorkingTerminal = ( + workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? [] + ).some((evidence) => + monitoringSources.every( + (source) => !runtimeWorkingTerminalEvidenceMatchesSource(evidence, source) + ) + ) + mergeWorktreeSummaryStatus( + summary, + 'working', + hasForegroundWorkingAgent || hasIndependentWorkingTerminal ? undefined : 'monitoring' + ) + } + } + } + } + + listRepos(): Repo[] { + return this.store?.getRepos() ?? [] + } + + // Why a stable field and not a per-call closure: enrichment dedupes coalesced callers by callback + // identity, so a fresh closure per call would stack up for the length of a slow sweep. + private readonly onRepoGitRemoteIdentitiesChanged = (): void => { + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + } + + enrichMissingRepoGitRemoteIdentities(): void { + if (!this.store) { + return + } + enrichMissingRepoGitRemoteIdentities(this.store, { + onChanged: this.onRepoGitRemoteIdentitiesChanged + }) + } + + listProjects(): Project[] { + return this.store?.getProjects?.() ?? [] + } + + updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project { + if (!this.store?.updateProject) { + throw new Error('runtime_unavailable') + } + const project = this.store.updateProject(projectId, updates) + if (!project) { + throw new Error(`Project not found: ${projectId}`) + } + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return project + } + + listProjectHostSetups(): ProjectHostSetup[] { + return this.store?.getProjectHostSetups?.() ?? [] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult { + if (!this.store?.createProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.createProjectHostSetup(args) + if (!result) { + throw new Error(`Project not found: ${args.projectId}`) + } + return result + } + + async setupProjectExistingFolder( + args: ProjectHostSetupExistingFolderArgs + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + assertProjectHostSetupHostIsSupported(args.hostId) + const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) + const repo = await this.addRepo( + args.path, + args.kind === 'folder' ? 'folder' : 'git', + args.hostId + ) + return this.completeProjectHostSetup(args, repo, !knownRepoIds.has(repo.id)) + } + + async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { + // Why: guard before cloneRepo, which would otherwise clone to the local disk. + assertProjectHostSetupHostIsSupported(args.hostId) + const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) + const repo = await this.cloneRepo(args.url, args.destination, args.hostId) + return this.completeProjectHostSetup( + { ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' }, + repo, + !knownRepoIds.has(repo.id) + ) + } + + private completeProjectHostSetup( + args: ProjectHostSetupExistingFolderArgs, + initialRepo: Repo, + repoWasCreated: boolean + ): ProjectHostSetupResult { + try { + return this.linkRepoToProjectHostSetup(args, initialRepo) + } catch (err) { + if (repoWasCreated) { + // Why: a failed link must not leave a new repo registration or stale host caches behind. + this.store?.removeProject?.(initialRepo.id) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(initialRepo.id) + invalidateAuthorizedRootsCache() + this.notifyReposChanged() + } + throw err + } + } + + private linkRepoToProjectHostSetup( + args: ProjectHostSetupExistingFolderArgs, + initialRepo: Repo + ): ProjectHostSetupResult { + if (!this.store) { + throw new Error('runtime_unavailable') + } + let repo = initialRepo + let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + if (setup.projectId !== args.projectId) { + const existingProject = this.listProjects().find((project) => project.id === args.projectId) + // Why: the selected project can exist only on the source host, so its structured identity travels with the request. + const identity = existingProject?.providerIdentity ?? args.projectProviderIdentity + if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) { + throw new Error('Imported folder does not match the selected project identity.') + } + const updated = this.store.updateRepo(repo.id, { + upstream: { + owner: identity.owner, + repo: identity.repo, + ...(identity.host ? { host: identity.host } : {}) + } + }) + if (!updated) { + throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) + } + repo = updated + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + } + const setupMethod = args.setupMethod ?? 'imported-existing-folder' + const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) + if (!updated) { + throw new Error( + `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` + ) + } + repo = updated + setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) + const project = this.listProjects().find((entry) => entry.id === setup.projectId) + if (!project) { + throw new Error(`Project setup was created without a project record: ${setup.projectId}`) + } + return { project, setup, repo } + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { + if (!this.store?.updateProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.updateProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + if ('worktreeBasePath' in args.updates && result.repo) { + void prepareLocalWorktreeRootForRepo(this.store, result.repo) + invalidateAuthorizedRootsCache() + } + return result + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult { + if (!this.store?.deleteProjectHostSetup) { + throw new Error('runtime_unavailable') + } + const result = this.store.deleteProjectHostSetup(args) + if (!result) { + throw new Error(`Project host setup not found: ${args.setupId}`) + } + return result + } + + listProjectGroups(): ProjectGroup[] { + return this.store?.getProjectGroups?.() ?? [] + } + + listFolderWorkspaces(): FolderWorkspace[] { + return this.store?.getFolderWorkspaces?.() ?? [] + } + + async createProjectGroup(input: { + name: string + parentPath?: string | null + connectionId?: string | null + parentGroupId?: string | null + createdFrom?: ProjectGroup['createdFrom'] + }): Promise { + if (!this.store?.createProjectGroup) { + throw new Error('runtime_unavailable') + } + const group = this.store.createProjectGroup({ + name: input.name, + parentPath: input.parentPath ?? null, + connectionId: input.connectionId ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom ?? 'manual' + }) + this.notifyReposChanged() + return group + } + + async updateProjectGroup( + groupId: string, + updates: Partial> + ): Promise { + if (!this.store?.updateProjectGroup) { + throw new Error('runtime_unavailable') + } + const updated = this.store.updateProjectGroup(groupId, updates) + if (updated) { + this.notifyReposChanged() + } + return updated + } + + async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> { + if (!this.store?.deleteProjectGroup) { + throw new Error('runtime_unavailable') + } + const deleted = this.store.deleteProjectGroup(groupId) + if (deleted) { + this.notifyReposChanged() + } + return { deleted } + } + + async moveProjectToGroup( + repoSelector: string, + groupId: string | null, + order?: number + ): Promise { + if (!this.store?.moveProjectToGroup) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const moved = this.store.moveProjectToGroup(repo.id, groupId, order) + if (!moved) { + throw new Error('repo_not_found') + } + this.notifyReposChanged() + return moved + } + + async createFolderWorkspace(input: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] + linkedTask?: FolderWorkspace['linkedTask'] + linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }): Promise { + if (!this.store?.createFolderWorkspace) { + throw new Error('runtime_unavailable') + } + const projectGroups = this.store.getProjectGroups?.() ?? [] + const group = projectGroups.find((entry) => entry.id === input.projectGroupId) + const folderPath = + typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 + ? input.folderPath + : group?.parentPath + if (!group || !folderPath) { + throw new Error('folder_workspace_project_group_not_found') + } + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath, + projectGroupId: group.id, + connectionId: input.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: this.store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + const workspace = this.store.createFolderWorkspace({ + ...input, + creatorProvenance: input.creatorProvenance ?? { kind: 'host' } + }) + this.notifyReposChanged() + return workspace + } + + async getFolderWorkspacePathStatus( + request: FolderWorkspacePathStatusRequest + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider }) + } + + async updateFolderWorkspace( + folderWorkspaceId: string, + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'linkedTaskSourceContext' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + | 'diffComments' + > + > + ): Promise { + if (!this.store?.updateFolderWorkspace) { + throw new Error('runtime_unavailable') + } + if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { + const workspace = this.store + .getFolderWorkspaces?.() + .find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + const projectGroups = this.store.getProjectGroups?.() ?? [] + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath: updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: this.store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + } + const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates) + if (updated) { + this.notifyReposChanged() + } + return updated + } + + async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> { + if (!this.store?.removeFolderWorkspace) { + throw new Error('runtime_unavailable') + } + const deleted = this.store.removeFolderWorkspace(folderWorkspaceId) + if (deleted) { + this.notifyReposChanged() + } + return { deleted } + } + + async scanNestedRepos(path: string): Promise { + if (!isAbsolute(path)) { + throw new Error('Project path must be an absolute path') + } + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + return scanNestedRepos({ path, options: { timeoutMs: 15_000 } }) + } + + async browseServerDir(pathValue: string): Promise<{ + resolvedPath: string + entries: DirEntry[] + pathFlavor: FilesystemPathFlavor + }> { + // Windows resolves `/` to the current drive, so expose drive roots instead. + if (isServerDriveListRequest(pathValue)) { + return listWindowsDrives() + } + const dirPath = resolveServerBrowsePath(pathValue) + const dirStat = await stat(dirPath) + if (!dirStat.isDirectory()) { + throw new Error(`${dirPath} is not a directory`) + } + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries + .filter((entry) => entry.name !== '.' && entry.name !== '..') + .map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + sortDirEntries(mapped) + return { + resolvedPath: dirPath, + entries: mapped, + pathFlavor: process.platform === 'win32' ? 'win32' : 'posix' + } + } + + async isGitAvailable(): Promise { + try { + await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 }) + return true + } catch { + return false + } + } + + async importNestedRepos(args: { + parentPath: string + groupName: string + projectPaths: string[] + mode: ProjectGroupImportMode + }): Promise { + await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath }) + if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { + throw new Error('runtime_unavailable') + } + if (!isAbsolute(args.parentPath)) { + throw new Error('Project path must be an absolute path') + } + const scan = await scanNestedRepos({ path: args.parentPath, options: { timeoutMs: 15_000 } }) + const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths }) + const groupResolver = createNestedProjectGroupResolver({ + parentPath: args.parentPath, + groupName: args.groupName, + mode: args.mode, + connectionId: null, + repoPaths: selection.selectedPaths, + createGroup: (input) => this.store!.createProjectGroup!(input) + }) + const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( + (repoPath) => ({ + path: repoPath, + status: 'failed', + error: 'Repository was not found in the nested repo scan result' + }) + ) + const importedProjectIdsByRepoPath = new Map() + const importTargetResolver = createNestedRepoImportTargetResolver() + for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { + try { + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) + if (!isGitRepo(repoPath)) { + results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) + continue + } + const importRepoPath = await importTargetResolver.resolveLocal(repoPath) + const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) + const alreadyImportedProjectId = importedProjectIdsByRepoPath.get(normalizedImportRepoPath) + if (alreadyImportedProjectId) { + results.push({ + path: repoPath, + projectId: alreadyImportedProjectId, + status: 'already-known' + }) + continue + } + const existing = this.store + .getRepos() + .find((repo) => normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath) + const group = groupResolver.getGroupForRepo(repoPath) + if (existing) { + if (group) { + this.store.moveProjectToGroup(existing.id, group.id, projectGroupOrder) + } + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id) + results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) + continue + } + const repo: Repo = { + id: randomUUID(), + path: importRepoPath, + displayName: getRepoName(importRepoPath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: Date.now(), + kind: 'git', + externalWorktreeVisibilityLegacy: false, + ...(group + ? { + projectGroupId: group.id, + projectGroupOrder + } + : {}) + } + this.store.addRepo(repo) + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) + results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) + } catch (error) { + results.push({ + path: repoPath, + status: 'failed', + error: sanitizeNestedRepoRuntimeImportError( + 'Failed to import nested repository in runtime', + error + ) + }) + } + } + const importedCount = results.filter((entry) => entry.status === 'imported').length + const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length + const failedCount = results.filter((entry) => entry.status === 'failed').length + if (importedCount + alreadyKnownCount === 0) { + for (const group of groupResolver.getCreatedGroups().toReversed()) { + this.store.deleteProjectGroup?.(group.id) + } + } + this.invalidateResolvedWorktreeCache() + for (const project of results) { + if (project.projectId) { + this.invalidateWorktreeScanCacheForRepo(project.projectId) + } + } + this.notifyReposChanged() + const rootGroup = groupResolver.getRootGroup() + return { + ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), + projects: results, + importedCount, + alreadyKnownCount, + failedCount + } + } + + async listSparsePresets(repoSelector: string) { + if (!this.store?.getSparsePresets) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + return this.store.getSparsePresets(repo.id) + } + + async saveSparsePreset( + repoSelector: string, + args: { id?: string; name: string; directories: string[] } + ) { + if (!this.store?.getSparsePresets || !this.store.saveSparsePreset) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const name = normalizeSparsePresetName(args.name) + const directories = normalizeSparsePresetDirectoriesForSave(args.directories) + const now = Date.now() + const existing = args.id + ? this.store.getSparsePresets(repo.id).find((preset) => preset.id === args.id) + : undefined + return this.store.saveSparsePreset({ + id: existing?.id ?? randomUUID(), + repoId: repo.id, + name, + directories, + createdAt: existing?.createdAt ?? now, + updatedAt: now + }) + } + + async addRepo( + path: string, + kind: 'git' | 'folder' = 'git', + executionHostId?: ExecutionHostId | null, + displayName?: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + if (!isAbsolute(path)) { + // Why: remote clients may run in a different cwd than the server. Require + // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. + throw new Error('Project path must be an absolute path') + } + if (kind === 'git') { + await awaitWindowsHostGitEnvironmentReady({ cwd: path }) + } + if (kind === 'git' && !isGitRepo(path)) { + throw new Error(`Not a valid git repository: ${path}`) + } + + const existing = this.store.getRepos().find((repo) => { + if (!runtimePathsEqual(repo.path, path)) { + return false + } + return runtimeRepoMatchesExecutionHost(repo, executionHostId) + }) + if (existing) { + // Only a runtime host backfills a legacy unstamped repo. An unstamped repo is + // indistinguishable from a genuine local repo (both have null executionHostId and + // connectionId), so we never stamp local/ssh onto it — that would re-attribute a + // real local project to the wrong host. Runtime is the only host that lost its + // identity to the pre-#7018 path-only import and needs the backfill. + if ( + existing.executionHostId == null && + parseExecutionHostId(executionHostId)?.kind === 'runtime' + ) { + const adopted = + this.store.updateRepo(existing.id, { executionHostId }) ?? + ({ ...existing, executionHostId } as Repo) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(existing.id) + this.notifyReposChanged() + return adopted + } + return existing + } + + const detected = await detectRepoIconAndUpstream({ repoPath: path, kind }) + const repo: Repo = { + id: randomUUID(), + path, + displayName: displayName?.trim() || getRepoName(path), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), + ...detected, + addedAt: Date.now(), + kind, + ...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) + } + this.store.addRepo(repo) + await prepareLocalWorktreeRootForRepo(this.store, repo) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + this.notifyReposChanged() + return this.store.getRepo(repo.id) ?? repo + } + + async createRepo( + parentPath: string, + name: string, + kind: 'git' | 'folder' = 'git' + ): Promise<{ repo: Repo } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedName = name.trim() + const trimmedParentPath = parentPath.trim() + const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' + if (!trimmedName) { + return { error: 'Name cannot be empty' } + } + if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!trimmedParentPath) { + return { error: 'Parent directory is required' } + } + if (!isAbsolute(trimmedParentPath)) { + return { error: 'Parent directory must be an absolute path' } + } + + const targetPath = join(trimmedParentPath, trimmedName) + const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (existing) { + return { repo: existing } + } + + let createdDir = false + try { + // Why: default create-project parents are host-home based and may not exist + // before the first project is created on a fresh runtime. + await mkdir(trimmedParentPath, { recursive: true }) + const existingStat = await stat(targetPath).catch((error: unknown) => { + if (isENOENT(error)) { + return null + } + throw error + }) + if (existingStat) { + if (!existingStat.isDirectory()) { + return { error: `"${trimmedName}" already exists at this location and is not a folder.` } + } + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { error: `"${trimmedName}" already exists at this location and is not empty.` } + } + } else { + await mkdir(targetPath, { recursive: false }) + createdDir = true + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to prepare directory: ${message}` } + } + + if (repoKind === 'git') { + let step: 'init' | 'commit' = 'init' + try { + await gitExecFileAsync(['init'], { cwd: targetPath }) + step = 'commit' + await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: targetPath + }) + } catch (error) { + if (createdDir) { + await rm(targetPath, { recursive: true, force: true }).catch(() => {}) + } else if (step === 'commit') { + await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) + } + const message = error instanceof Error ? error.message : String(error) + if ( + step === 'commit' && + /Please tell me who you are|user\.name|user\.email/i.test(message) + ) { + return { + error: + 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' + } + } + const stepLabel = + step === 'init' + ? 'Failed to initialize git repository' + : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + const raceWinner = this.store + .getRepos() + .find((repo) => runtimePathsEqual(repo.path, targetPath)) + if (raceWinner) { + return { repo: raceWinner } + } + + const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) + const repo: Repo = { + id: randomUUID(), + path: targetPath, + displayName: trimmedName, + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...detected, + addedAt: Date.now(), + kind: repoKind, + ...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) + } + this.store.addRepo(repo) + await prepareLocalWorktreeRootForRepo(this.store, repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + this.notifyReposChanged() + return { repo: this.store.getRepo(repo.id) ?? repo } + } + + async cloneRepo( + url: string, + destination: string, + executionHostId?: ExecutionHostId | null + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const trimmedUrl = url.trim() + const trimmedDestination = destination.trim() + if (!trimmedDestination) { + throw new Error('Clone destination is required') + } + const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination }) + const clonePathKey = getClonePathComparisonKey(clonePath) + const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.cloneInFlightByPath.set(clonePathKey, tail) + + try { + await previous + return await runWithGitReadCacheInvalidation(() => + this.cloneRepoAfterPathLock( + trimmedUrl, + trimmedDestination, + clonePath, + clonePathKey, + executionHostId + ) + ) + } finally { + release() + if (this.cloneInFlightByPath.get(clonePathKey) === tail) { + this.cloneInFlightByPath.delete(clonePathKey) + } + } + } + + private async cloneRepoAfterPathLock( + trimmedUrl: string, + trimmedDestination: string, + clonePath: string, + clonePathKey: string, + executionHostId?: ExecutionHostId | null + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const existingBeforeClone = this.store + .getRepos() + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) + if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { + return existingBeforeClone + } + + await mkdir(trimmedDestination, { recursive: true }) + const claimedTarget = await claimCloneTarget(clonePath) + let proc: Awaited> + try { + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', trimmedUrl, clonePath], + { + cwd: trimmedDestination, + admissionTier: 'interactive', + // Why: without the non-interactive guard, a clone that needs GitHub + // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth + // window on Windows; in a network-restricted env the browser/device + // flow can never complete and git's credential retry re-pops it + // (issue #7652). Fail fast with a clear error instead. + env: nonInteractiveGitEnv(), + stdio: ['ignore', 'ignore', 'pipe'] + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } + await new Promise((resolve, reject) => { + let stderrTail = '' + let settled = false + proc.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4096) + }) + const finishClone = async ( + code: number | null, + signal: NodeJS.Signals | null, + error?: Error + ) => { + if (settled) { + return + } + settled = true + const cloneSucceeded = !error && code === 0 && !signal + if (!cloneSucceeded) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + } + + if (error) { + reject(new Error(`Clone failed: ${error.message}`)) + } else if (signal === 'SIGTERM') { + reject(new Error('Clone aborted')) + } else if (code === 0) { + resolve() + } else { + reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)) + } + } + proc.on('error', (error) => { + void finishClone(null, null, error) + }) + proc.on('close', (code, signal) => { + void finishClone(code, signal) + }) + }) + + const existing = this.store + .getRepos() + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) + if (existing) { + if (isFolderRepo(existing)) { + const updated = this.store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + await prepareLocalWorktreeRootForRepo(this.store, updated) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(updated.id) + this.notifyReposChanged() + return updated + } + } + return existing + } + + const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) + const repo: Repo = { + id: randomUUID(), + path: clonePath, + displayName: getRepoName(clonePath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), + ...detected, + addedAt: Date.now(), + kind: 'git', + externalWorktreeVisibilityLegacy: false + } + this.store.addRepo(repo) + await prepareLocalWorktreeRootForRepo(this.store, repo) + invalidateAuthorizedRootsCache() + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + this.notifyReposChanged() + return this.store.getRepo(repo.id) ?? repo + } + + async showRepo(repoSelector: string): Promise { + return await this.resolveRepoSelector(repoSelector) + } + + async setRepoBaseRef(repoSelector: string, baseRef: string): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + throw new Error('Folder mode does not support base refs.') + } + const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef }) + if (!updated) { + throw new Error('repo_not_found') + } + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return updated + } + + async updateRepo( + repoSelector: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const sanitizedUpdates = omitUndefinedProperties(updates) + if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { + sanitizedUpdates.worktreeBasePath = undefined + } + if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) { + sanitizedUpdates.externalWorktreeVisibility = undefined + } + if ( + 'externalWorktreeDiscoverySuppressedAt' in updates && + updates.externalWorktreeDiscoverySuppressedAt === null + ) { + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined + } + if ('sourceControlAi' in updates && updates.sourceControlAi === null) { + sanitizedUpdates.sourceControlAi = null + } + const updated = this.store.updateRepo(repo.id, sanitizedUpdates) + if (!updated) { + throw new Error('repo_not_found') + } + if ('worktreeBasePath' in updates) { + await prepareLocalWorktreeRootForRepo(this.store, updated) + invalidateAuthorizedRootsCache() + } + this.invalidateResolvedWorktreeCache() + if ('worktreeBasePath' in updates) { + this.invalidateWorktreeScanCacheForRepo(repo.id) + } + this.notifyReposChanged() + return updated + } + + async removeProject(repoSelector: string): Promise<{ removed: true }> { + if (!this.store?.removeProject) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + // Why: removeProject is id-only, but the same id may be registered on a sibling + // execution host; a path:/name: selector resolves one row and must remove only it. + const hostId = getRepoExecutionHostId(repo) + const idExistsOnOtherHost = this.store + .getRepos() + .some((entry) => entry.id === repo.id && getRepoExecutionHostId(entry) !== hostId) + if (idExistsOnOtherHost) { + if (!this.store.removeProjectForHost) { + throw new Error('runtime_unavailable') + } + this.store.removeProjectForHost(repo.id, hostId) + } else { + this.store.removeProject(repo.id) + } + this.terminalTopologyRevisionByRepoId.delete(repo.id) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + invalidateAuthorizedRootsCache() + this.notifyReposChanged() + return { removed: true } + } + + async inspectTerminalProcess( + terminalSelector: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { + const leaf = this.resolveLiveLeafForHandle(terminalSelector) + if (!leaf?.ptyId || !this.ptyController) { + throw new Error('terminal_gone') + } + if (this.ptyController.inspectProcess) { + return this.ptyController.inspectProcess(leaf.ptyId) + } + const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) + const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false + return { foregroundProcess, hasChildProcesses } + } + + reorderRepos(orderedIds: string[]): { status: 'applied' | 'rejected' } { + if (!this.store?.reorderRepos) { + throw new Error('runtime_unavailable') + } + // Why: remote clients can race repo add/remove on the server just like + // local drag-reorder can race another window. Let the store validate the + // full permutation and signal a resync-worthy rejection. + const applied = this.store.reorderRepos(orderedIds) + if (!applied) { + return { status: 'rejected' } + } + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return { status: 'applied' } + } + + async searchRepoRefs( + repoSelector: string, + query: string, + limit = DEFAULT_REPO_SEARCH_REFS_LIMIT + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { + refs: [], + truncated: false + } + } + const refDetails = repo.connectionId + ? await this.searchRemoteRepoRefs(repo, query, limit + 1) + : await searchBaseRefDetails(repo.path, query, limit + 1) + return { + refs: refDetails.slice(0, limit).map((entry) => entry.refName), + refDetails: refDetails.slice(0, limit), + truncated: refDetails.length > limit + } + } + + async getRepoBaseRefDefault( + repoSelector: string + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { defaultBaseRef: null, remoteCount: 0 } + } + if (repo.connectionId) { + return this.getRemoteRepoBaseRefDefault(repo) + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + getBaseRefDefault(repo.path), + getRemoteCount(repo.path) + ]) + return { defaultBaseRef, remoteCount } + } + + private async getRemoteRepoBaseRefDefault( + repo: Repo + ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return { defaultBaseRef: null, remoteCount: 0 } + } + const [defaultBaseRef, remoteCount] = await Promise.all([ + resolveDefaultBaseRefViaExec(async (argv) => { + try { + return await provider.exec(argv, repo.path) + } catch (err) { + if (argv[0] === 'symbolic-ref') { + console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { + path: repo.path, + err + }) + } + throw err + } + }), + provider + .exec(['remote'], repo.path) + .then((result) => parseRemoteCount(result.stdout)) + .catch((err) => { + console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { + path: repo.path, + err + }) + return 0 + }) + ]) + return { defaultBaseRef, remoteCount } + } + + private async searchRemoteRepoRefs( + repo: Repo, + query: string, + limit: number + ): Promise { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (!provider) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + try { + const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) + const remotes = remotesResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const capabilities = getSshGitCapabilityCache(provider) + const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { + return capabilities.runWithFallback( + 'for-each-ref-exclude', + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + isForEachRefExcludeUnsupportedError + ) + } + const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) + if (searchTokens.length > 1) { + const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) + return mergeBaseRefSearchResultGroups( + results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), + limit + ) + } + return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) + } catch (err) { + console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { + path: repo.path, + err + }) + return [] + } + } + + private async resolveHostedReviewTarget(args: { + repoSelector: string + worktreeSelector?: string + }): Promise<{ repo: Repo; repoPath: string }> { + const repo = await this.resolveRepoSelector(args.repoSelector) + if (!args.worktreeSelector) { + return { repo, repoPath: repo.path } + } + + const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) + if (worktree.repoId !== repo.id) { + throw new Error('Access denied: worktree does not belong to repository') + } + return { repo, repoPath: worktree.path } + } + + private getHostedReviewExecutionOptions( + repo: Repo, + admissionTier?: GitAdmissionTier + ): + | { + localGitExecOptions: { + wslDistro?: string + admissionTier?: GitAdmissionTier + } + } + | undefined { + const localGitOptions = { + ...this.getLocalGitExecutionOptionArgs(repo)[0], + ...(admissionTier && { admissionTier }) + } + return Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : undefined + } + + private getLocalGitExecutionOptionArgs(repo: Repo): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] + } + + private getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform { + const projectRuntime = repo.connectionId + ? undefined + : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) + return getAgentLaunchPlatformForRepo(repo, projectRuntime) + } + + private getAgentLaunchPlatformForWorkspace(scope: TerminalWorkspaceLaunchScope): NodeJS.Platform { + if (scope.repo) { + return this.getAgentLaunchPlatformForRepo(scope.repo) + } + if (scope.connectionId) { + return isWindowsAbsolutePathLike(scope.path) ? 'win32' : 'linux' + } + return isWslUncPath(scope.path) ? 'linux' : process.platform + } + + async getRepoSlug(repoSelector: string): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + const options = this.getHostedReviewExecutionOptions(repo) + return options + ? getRepoSlug(repo.path, repo.connectionId ?? null, options) + : getRepoSlug(repo.path, repo.connectionId ?? null) + } + + async getRepoUpstream(repoSelector: string): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + const options = this.getHostedReviewExecutionOptions(repo) + return options + ? getRepoUpstream(repo.path, repo.connectionId ?? null, options) + : getRepoUpstream(repo.path, repo.connectionId ?? null) + } + + // Why: repos added before fork detection existed have no stored `upstream`, so + // their avatar/badge would never self-correct. Resolve it once at startup for + // local git repos; SSH repos resolve lazily when their settings open (their + // connection may not be up yet). Sequential to respect the gh rate limit; + // failures leave `upstream` unset so the next launch retries. + private async backfillForkUpstreams(): Promise { + try { + const store = this.requireStore() + let changed = false + for (const repo of store.getRepos()) { + if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { + continue + } + let upstream: GitHubOwnerRepo | null + try { + upstream = await getRepoUpstream(repo.path, null) + } catch { + continue + } + const repoIcon = + upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github' + ? await detectGitHubAvatarIcon(repo.path, null, upstream) + : null + // Why: settings can change the repo while the probes above are pending, so + // re-read it — a stale snapshot must not clobber a user-chosen icon or an + // upstream another path already resolved. + const current = store.getRepos().find((candidate) => candidate.id === repo.id) + if (!current || current.upstream !== undefined) { + continue + } + const updates: Partial = { upstream: upstream ?? null } + // Only migrate the auto-detected origin avatar; never touch a chosen icon. + if ( + repoIcon && + current.repoIcon?.type === 'image' && + current.repoIcon.source === 'github' + ) { + updates.repoIcon = repoIcon + } + store.updateRepo(repo.id, updates) + changed = true + } + if (changed) { + this.notifyReposChanged() + } + } catch { + // Best-effort startup backfill; never disrupt launch. + } + } + + async listRepoWorkItems( + repoSelector: string, + limit?: number, + query?: string, + page?: number, + noCache?: boolean + ): Promise> { + const repo = await this.resolveRepoSelector(repoSelector) + return listWorkItems( + repo.path, + limit, + query, + page, + repo.issueSourcePreference, + repo.connectionId ?? null, + noCache, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listRepoIssues( + repoSelector: string, + limit?: number + ): Promise>['items']> { + const repo = await this.resolveRepoSelector(repoSelector) + const result = await listGitHubIssues( + repo.path, + limit, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + return result.items + } + + async getRepoWorkItem( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + // Why: open-by-number must pin the same source the list and start-point use, + // else a fork and its upstream sharing a PR number resolve to different PRs. + return getWorkItem( + repo.path, + number, + type, + repo.connectionId ?? null, + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference + ) + } + + async getRepoWorkItemByOwnerRepo( + repoSelector: string, + ownerRepo: { owner: string; repo: string; host?: string }, + number: number, + type: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getWorkItemByOwnerRepo( + repo.path, + ownerRepo, + number, + type, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getRepoWorkItemDetails( + repoSelector: string, + number: number, + type?: 'issue' | 'pr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getWorkItemDetails( + repo.path, + number, + type, + repo.connectionId ?? null, + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference + ) + } + + async countRepoWorkItems(repoSelector: string, query?: string): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + return countWorkItems( + repo.path, + query, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listRepoLabels(repoSelector: string): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listLabels( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listRepoAssignableUsers( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + getGitHubRateLimit(options?: { + force?: boolean + }): Promise>> { + return getRateLimit(options) + } + + async getRepoPRForBranch( + repoSelector: string, + branch: string, + linkedPRNumber?: number | null, + fallbackPRNumber?: number | null, + acceptMergedFallbackPR?: boolean, + currentHeadOid?: string | null, + reason?: GitHubPRRefreshReason + ): Promise { + const repo = await this.resolveRepoSelector(repoSelector) + const options: GitHubPRBranchLookupOptions = + this.getHostedReviewExecutionOptions( + repo, + reason ? admissionTierForRefreshReason(reason) : undefined + ) ?? {} + const lookupOptions = { ...options } + if (acceptMergedFallbackPR === true) { + lookupOptions.acceptMergedFallbackPR = true + } + if (typeof currentHeadOid === 'string' && currentHeadOid.trim().length > 0) { + lookupOptions.currentHeadOid = currentHeadOid.trim() + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = + Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] + // Why: return the full classified outcome (not PRInfo|null) so a runtime gh + // auth/network failure crosses the RPC as `upstream-error` instead of + // collapsing to `null`, which the renderer would otherwise cache as a false + // accepted "no PR found" (design success criterion 1). + return getPRForBranchOutcome( + repo.path, + branch, + linkedPRNumber ?? null, + repo.connectionId ?? null, + linkedPRNumber == null ? (fallbackPRNumber ?? null) : null, + ...lookupOptionArgs + ) + } + + async getHostedReviewForBranch(args: { + repoSelector: string + branch: string + admissionTier?: GitAdmissionTier + currentHeadOid?: string | null + active?: boolean + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + }): Promise { + const repo = await this.resolveRepoSelector(args.repoSelector) + const executionOptions = this.getHostedReviewExecutionOptions( + repo, + args.admissionTier ?? 'background' + ) + const review = await getHostedReviewForBranchFromRepo({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + branch: args.branch, + currentHeadOid: args.currentHeadOid ?? null, + ...(args.active === true ? { active: true } : {}), + linkedGitHubPR: args.linkedGitHubPR ?? null, + fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null, + ...executionOptions + }) + if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: review.number, prUrl: review.url } + }) + } + return review + } + + async getHostedReviewCreationEligibility( + args: Omit & { + repoSelector: string + worktreeSelector?: string + } + ): Promise { + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') + return getHostedReviewCreationEligibilityFromRepo({ + repoPath, + connectionId: repo.connectionId ?? null, + branch: args.branch, + base: args.base ?? null, + hasUncommittedChanges: args.hasUncommittedChanges, + hasUpstream: args.hasUpstream, + ahead: args.ahead, + behind: args.behind, + linkedGitHubPR: args.linkedGitHubPR ?? null, + fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null, + ...executionOptions + }) + } + + async createHostedReview( + args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } + ): Promise { + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') + const input = { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + } + const result = executionOptions + ? await createHostedReviewFromRepo( + repoPath, + input, + repo.connectionId ?? null, + executionOptions + ) + : await createHostedReviewFromRepo(repoPath, input, repo.connectionId ?? null) + if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + + async createStackedHostedReview( + args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string } + ): Promise { + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') + const result = await createStackedHostedReviewFromRepo( + repoPath, + { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + }, + repo.connectionId ?? null, + executionOptions ?? {} + ) + if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { + this.stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: result.number, prUrl: result.url } + }) + } + return result + } + + async listGitLabRepoWorkItems( + repoSelector: string, + state?: MRListState, + page?: number, + perPage?: number, + query?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabWorkItems( + repo.path, + state ?? 'opened', + page ?? 1, + perPage ?? 20, + repo.issueSourcePreference, + query, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listGitLabRepoMRs( + repoSelector: string, + state?: MRListState, + page?: number, + perPage?: number, + query?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabMergeRequests( + repo.path, + normalizeGitLabMRListState(state), + normalizeGitLabPositiveInteger(page, 1, 10_000), + normalizeGitLabPositiveInteger(perPage, 20, 100), + repo.issueSourcePreference, + query, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listGitLabRepoIssues( + repoSelector: string, + state?: GitLabIssueListState, + assignee?: string, + limit?: number, + page?: number + ): Promise<{ + items: GitLabWorkItem[] + totalPages: number + error?: Awaited>['error'] + }> { + const repo = await this.resolveRepoSelector(repoSelector) + const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit, page }) + // Why: page is after localGitOptions; never spread optional args before it (#13538). + const result = await listGitLabIssues( + repo.path, + normalized.limit, + repo.issueSourcePreference, + normalized.state, + normalized.assignee, + repo.connectionId ?? null, + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + normalized.page + ) + // Why: web runtime mirrors the desktop preload contract, where GitLab + // issue rows share the GitLabWorkItem shape with MRs on TaskPage. + const items: GitLabWorkItem[] = result.items.map((issue) => ({ + id: `gitlab-issue-${repo.id}-${issue.number}`, + type: 'issue' as const, + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.url, + labels: issue.labels, + updatedAt: issue.updatedAt ?? '', + author: issue.author ?? null, + repoId: repo.id + })) + return { + items, + totalPages: result.totalPages, + ...(result.error ? { error: result.error } : {}) + } + } + + async listGitLabRepoTodos( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabTodos( + repo.path, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async diagnoseGitLabAuth(): Promise>> { + return diagnoseGitLabAuthClient() + } + + async getGitLabRateLimit(options?: { + force?: boolean + host?: string | null + }): Promise>> { + return getGitLabRateLimit(options) + } + + async listGitLabRepoLabels( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabLabels( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async createGitLabRepoIssue( + repoSelector: string, + title: string, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return createGitLabIssue( + repo.path, + title, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateGitLabRepoIssue( + repoSelector: string, + number: number, + updates: GitLabIssueUpdate, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabIssue( + repo.path, + number, + updates, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async addGitLabRepoIssueComment( + repoSelector: string, + number: number, + body: string, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabIssueComment( + repo.path, + number, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async addGitLabRepoMRComment( + repoSelector: string, + iid: number, + body: string, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabMRComment( + repo.path, + iid, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async addGitLabRepoMRInlineComment( + repoSelector: string, + iid: number, + input: GitLabMRInlineCommentInput, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabMRInlineComment( + repo.path, + iid, + input, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async resolveGitLabRepoMRDiscussion( + repoSelector: string, + iid: number, + discussionId: string, + resolved: boolean, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return resolveGitLabMRDiscussion( + repo.path, + iid, + discussionId, + resolved, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getGitLabRepoJobTrace( + repoSelector: string, + jobId: number, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getGitLabJobTrace( + repo.path, + jobId, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async retryGitLabRepoJob( + repoSelector: string, + jobId: number, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return retryGitLabJob( + repo.path, + jobId, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async mergeGitLabRepoMR( + repoSelector: string, + iid: number, + method?: 'merge' | 'squash' | 'rebase', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return mergeGitLabMR( + repo.path, + iid, + method ?? 'merge', + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateGitLabRepoMRState( + repoSelector: string, + iid: number, + state: 'opened' | 'closed', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return state === 'closed' + ? closeGitLabMR( + repo.path, + iid, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + : reopenGitLabMR( + repo.path, + iid, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateGitLabRepoMR( + repoSelector: string, + iid: number, + updates: GitLabMRUpdate, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabMR( + repo.path, + iid, + updates, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateGitLabRepoMRReviewers( + repoSelector: string, + iid: number, + reviewerIds: number[], + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabMRReviewers( + repo.path, + iid, + reviewerIds, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getGitLabRepoWorkItemDetails( + repoSelector: string, + iid: number, + type: 'issue' | 'mr', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getGitLabWorkItemDetails( + repo.path, + iid, + type, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getGitLabRepoWorkItemByPath( + repoSelector: string, + projectRef: GitLabProjectRef, + iid: number, + type: 'issue' | 'mr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + const result = await getGitLabWorkItemByProjectRef( + repo.path, + projectRef, + iid, + type, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + // Why: remote pasted-URL lookups should update GitLab recents exactly + // like the desktop IPC path, but only after a successful lookup. + if (result && this.store?.updateSettings) { + const store = this.store + recordGitLabProjectRecent( + { + getSettings: () => store.getSettings(), + updateSettings: (updates) => store.updateSettings?.(updates) + }, + projectRef.host, + projectRef.path + ) + } + return result + } + + async getRepoIssue( + repoSelector: string, + number: number + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getIssue( + repo.path, + number, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getRepoPRChecks( + repoSelector: string, + prNumber: number, + headSha?: string, + prRepo?: GitHubOwnerRepo | null, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getPRChecks( + repo.path, + prNumber, + headSha, + prRepo ?? null, + options, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async rerunRepoPRChecks( + repoSelector: string, + prNumber: number, + options?: { + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return rerunPRChecks( + repo.path, + prNumber, + options, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getRepoPRCheckDetails( + repoSelector: string, + args: { + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }, + signal?: AbortSignal + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} + return getPRCheckDetails( + repo.path, + { ...args, prRepo: args.prRepo ?? null }, + repo.connectionId ?? null, + localGitOptions, + signal + ) + } + + async getRepoPRComments( + repoSelector: string, + prNumber: number, + prRepo?: GitHubOwnerRepo | null, + options?: { noCache?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getPRComments( + repo.path, + prNumber, + { ...options, prRepo: prRepo ?? null }, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async setRepoPRCommentReaction( + repoSelector: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRCommentReaction( + repo.path, + reactionSubjectId, + content, + reacted, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async getRepoPRFileContents( + repoSelector: string, + args: { + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getPRFileContents({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], + ...args + }) + } + + async resolveRepoReviewThread( + repoSelector: string, + threadId: string, + resolve: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return resolveReviewThread( + repo.path, + threadId, + resolve, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async setRepoPRFileViewed( + repoSelector: string, + args: { + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRFileViewed({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], + ...args + }) + } + + async updateRepoPRTitle( + repoSelector: string, + prNumber: number, + title: string, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updatePRTitle( + repo.path, + prNumber, + title, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateRepoPRDetails( + repoSelector: string, + prNumber: number, + updates: { title?: string; body?: string }, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updatePRDetails( + repo.path, + prNumber, + updates, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async mergeRepoPR( + repoSelector: string, + prNumber: number, + method?: 'merge' | 'squash' | 'rebase', + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return mergePR( + repo.path, + prNumber, + method, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async setRepoPRAutoMerge( + repoSelector: string, + prNumber: number, + enabled: boolean, + method?: 'merge' | 'squash' | 'rebase', + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRAutoMerge( + repo.path, + prNumber, + enabled, + method, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async markRepoPRReadyForReview( + repoSelector: string, + prNumber: number, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return markPRReadyForReview( + repo.path, + prNumber, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateRepoPRState( + repoSelector: string, + prNumber: number, + updates: GitHubPullRequestStateUpdate, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updatePRState( + repo.path, + prNumber, + updates, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async requestRepoPRReviewers( + repoSelector: string, + prNumber: number, + reviewers: string[], + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return requestPRReviewers( + repo.path, + prNumber, + reviewers, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async removeRepoPRReviewers( + repoSelector: string, + prNumber: number, + reviewers: string[], + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return removePRReviewers( + repo.path, + prNumber, + reviewers, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async createRepoIssue( + repoSelector: string, + title: string, + body: string, + fields?: GitHubCreateIssueFields + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return createIssue( + repo.path, + title, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + fields, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async updateRepoIssue( + repoSelector: string, + number: number, + updates: GitHubIssueUpdate + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateIssue( + repo.path, + number, + updates, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async addRepoIssueComment( + repoSelector: string, + number: number, + body: string, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addIssueComment( + repo.path, + number, + body, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async addRepoPRReviewComment( + repoSelector: string, + args: Omit + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addPRReviewComment({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], + ...args + }) + } + + async addRepoPRReviewCommentReply( + repoSelector: string, + args: { + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body, + args.threadId, + args.path, + args.line, + repo.connectionId ?? null, + args.prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + + async listGitHubProjects( + args?: ListAccessibleProjectsArgs + ): Promise>> { + return listAccessibleProjects(args) + } + + async listGitHubLabelsBySlug( + args: ListLabelsBySlugArgs + ): Promise>> { + return listLabelsBySlug(args) + } + + async listGitHubAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs + ): Promise>> { + return listAssignableUsersBySlug(args) + } + + async listGitHubIssueTypesBySlug( + args: ListIssueTypesBySlugArgs + ): Promise>> { + return listIssueTypesBySlug(args) + } + + async resolveGitHubProjectRef( + args: ResolveProjectRefArgs + ): Promise>> { + return resolveProjectRef(args) + } + + async listGitHubProjectViews( + args: ListProjectViewsArgs + ): Promise>> { + return listProjectViews(args) + } + + async getGitHubProjectViewTable( + args: GetProjectViewTableArgs + ): Promise>> { + return getProjectViewTable(args) + } + + async getGitHubProjectWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise>> { + return getWorkItemDetailsBySlug(args) + } + + async updateGitHubProjectItemField( + args: UpdateProjectItemFieldArgs + ): Promise>> { + return updateProjectItemFieldValue(args) + } + + async clearGitHubProjectItemField( + args: ClearProjectItemFieldArgs + ): Promise>> { + return clearProjectItemFieldValue(args) + } + + async updateGitHubIssueBySlug( + args: UpdateIssueBySlugArgs + ): Promise>> { + return updateIssueBySlug(args) + } + + async updateGitHubPullRequestBySlug( + args: UpdatePullRequestBySlugArgs + ): Promise>> { + return updatePullRequestBySlug(args) + } + + async updateGitHubIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs + ): Promise>> { + return updateIssueTypeBySlug(args) + } + + async addGitHubIssueCommentBySlug( + args: AddIssueCommentBySlugArgs + ): Promise>> { + return addIssueCommentBySlug(args) + } + + async updateGitHubIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs + ): Promise>> { + return updateIssueCommentBySlug(args) + } + + async deleteGitHubIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs + ): Promise>> { + return deleteIssueCommentBySlug(args) + } + + private getSetupHookTrustPayload( + repo: Repo, + scriptContentValue: string | undefined + ): { contentHash: string; scriptContent: string } | undefined { + const scriptContent = scriptContentValue?.trim() + if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { + return undefined + } + return { + contentHash: createHash('sha256').update(scriptContent).digest('hex'), + scriptContent + } + } + + private getSharedSetupHookTrustPayload( + repo: Repo, + sharedSetupScript: string | undefined + ): { contentHash: string; scriptContent: string } | undefined { + if (repo.hookSettings?.commandSourcePolicy === 'local-only') { + return undefined + } + return this.getSetupHookTrustPayload(repo, sharedSetupScript) + } + + async getRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const hooks = result.isBinary ? null : parseOrcaYaml(result.content) + return { + hasHooksFile: Boolean(hooks), + hooks, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: hooks ? 'orca.yaml' : null, + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(hooks) + ) + } + } catch { + return { + hasHooksFile: false, + hooks: null, + setupRunPolicy: getEffectiveSetupRunPolicy(repo), + source: null + } + } + } + const hasFile = hasHooksFile(repo.path) + const hooks = getEffectiveHooks(repo) + const sharedHooks = hasFile ? loadHooks(repo.path) : null + const setupRunPolicy = getEffectiveSetupRunPolicy(repo) + return { + hasHooksFile: hasFile, + hooks, + setupRunPolicy, + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(sharedHooks) + ) + } + } + + async checkRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + // Why: callers cache "no hooks" as authoritative, so an unreadable repo must fail + // closed with an error status (mirrors the hooks:check IPC handler) instead of + // pinning a false "no setup script" verdict until the client remounts. + if (!fsProvider) { + return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + if (result.isBinary) { + return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } + } + return { + status: 'ok' as const, + hasHooks: true, + hooks: parseOrcaYaml(result.content), + mayNeedUpdate: false + } + } catch (error) { + return { + status: isENOENT(error) ? ('ok' as const) : ('error' as const), + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + return { + status: 'ok' as const, + hasHooks: has, + hooks, + mayNeedUpdate: has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + } + } + + async inspectRepoSetupScriptImports(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return [] + } + + return inspectSetupScriptImportCandidates(async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return null + } + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content + } catch { + return null + } + } + + try { + return await readFile(filePath, 'utf-8') + } catch (error) { + if (!isENOENT(error)) { + console.warn('[runtime] Failed to inspect setup script import candidate:', error) + } + return null + } + }) + } + + async readRepoIssueCommand(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + const localContent = await this.readRemoteIssueCommandOverride(fsProvider, issueCommandPath) + const sharedContent = await this.readRemoteSharedIssueCommand(fsProvider, repo.path) + const effectiveContent = localContent ?? sharedContent + return { + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + + return readIssueCommand(repo.path) + } + + private async readRemoteIssueCommandOverride( + fsProvider: IFilesystemProvider, + issueCommandPath: string + ): Promise { + try { + const result = await fsProvider.readFile(issueCommandPath) + if (result.isBinary) { + return null + } + return result.content.trim() || null + } catch { + return null + } + } + + private async readRemoteSharedIssueCommand( + fsProvider: IFilesystemProvider, + repoPath: string + ): Promise { + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) + if (result.isBinary) { + return null + } + return parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch { + return null + } + } + + async writeRepoIssueCommand(repoSelector: string, content: string): Promise<{ ok: true }> { + const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + return { ok: true } + } + + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { ok: true } + } + const trimmed = content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return { ok: true } + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + await this.ensureRemoteOrcaDirIgnored(fsProvider, repo.path) + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return { ok: true } + } + + writeIssueCommand(repo.path, content) + return { ok: true } + } + + private async ensureRemoteOrcaDirIgnored( + fsProvider: IFilesystemProvider, + repoPath: string, + options: { required?: boolean } = {} + ): Promise { + const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') + let result: Awaited> + try { + result = await fsProvider.readFile(gitignorePath) + } catch (error) { + if (!isENOENT(error)) { + if (options.required) { + throw error + } + console.warn('[runtime] Could not inspect remote .gitignore for .orca', error) + return + } + try { + await fsProvider.writeFile(gitignorePath, '.orca\n') + } catch (writeError) { + if (options.required) { + throw writeError + } + console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) + } + return + } + if (result.isBinary) { + if (options.required) { + throw new Error('Remote .gitignore is binary; cannot verify .orca is ignored') + } + return + } + if (/^\.orca\/?$/m.test(result.content)) { + return + } + const separator = result.content.endsWith('\n') ? '' : '\n' + try { + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } catch (writeError) { + if (options.required) { + throw writeError + } + console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) + } + } + + async listManagedWorktrees( + repoSelector?: string, + limit = DEFAULT_WORKTREE_LIST_LIMIT, + sourceDefaultsSupported = true + ): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const resolved = await this.listResolvedWorktrees() + const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null + const settings = this.store?.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings?.worktreeVisibilityDefaults + : settings?.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = settings + ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } + : undefined + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolved, + visibilityDefaults + ) + const worktrees = resolved.filter((worktree) => { + if (repoId && worktree.repoId !== repoId) { + return false + } + return this.isRuntimeWorktreeVisible( + worktree, + visibilitySourceMatchersByRepoId.get(worktree.repoId), + visibilitySettings + ) + }) + return { + worktrees: worktrees.slice(0, limit), + totalCount: worktrees.length, + truncated: worktrees.length > limit + } + } + + /** Keyed by repo id on the wire even though one repo is requested: the caller asked by selector + * and needs to know which repo answered. + * + * The tier watermark rides alongside the names rather than being expanded into them — expanding + * it would put 552 strings per spent tier on the wire, which is what compaction exists to + * avoid. A client predating the field reads the names only and under-retires, degrading to the + * pre-retirement behavior for compacted tiers instead of breaking. */ + async listRetiredWorktreeNames(repoSelector: string): Promise<{ + retiredNamesByRepo: Record + retiredNameTiersByRepo: Record + }> { + const store = this.store + if (!store) { + return { retiredNamesByRepo: {}, retiredNameTiersByRepo: {} } + } + const repo = await this.resolveRepoSelector(repoSelector) + const settings = store.getSettings() + const registry: RetiredNameRegistry = await getRetiredNameRegistryForRepo( + store, + repo, + store.getRepos(), + settings + ) + return { + retiredNamesByRepo: { [repo.id]: registry.names }, + retiredNameTiersByRepo: { [repo.id]: registry.exhaustedTiers } + } + } + + async listDetectedManagedWorktrees( + repoSelector: string, + connectionId?: string | null, + sourceDefaultsSupported = true + ): Promise { + return this.listDetectedWorktreesForResolvedRepo( + await this.resolveRepoSelectorForConnection(repoSelector, connectionId), + sourceDefaultsSupported + ) + } + + private async listDetectedWorktreesForResolvedRepo( + repo: Repo, + sourceDefaultsSupported = true + ): Promise { + const store = this.requireStore() + const settings = store.getSettings() + const visibilityDefaults = sourceDefaultsSupported + ? settings.worktreeVisibilityDefaults + : settings.worktreeVisibilityDefaults + ? { external: settings.worktreeVisibilityDefaults.external } + : undefined + const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults } + if (isFolderRepo(repo)) { + const worktrees = listRuntimeFolderWorkspaces(store, repo) + const metaById = store.getAllWorktreeMeta() + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const matcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = worktrees.map((worktree) => + this.toRuntimeDetectedWorktree( + repo, + worktree, + matcher, + visibilitySettings, + getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null + ) + ) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) + } + } + let scan: RuntimeWorktreeScanResult + try { + scan = await this.listRepoWorktreesForResolution(repo) + } catch { + scan = { ok: false, worktrees: [] } + } + if (scan.ok) { + pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees) + } + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + const metaById = store.getAllWorktreeMeta() + const detected = scan.worktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + // A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it. + const meta = + readWorktreeMetaForHost(store, worktreeId, expectedHostId) ?? + getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) + const worktree = { + ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId + } + const detectedWorktree = this.toRuntimeDetectedWorktree( + repo, + worktree, + worktreeVisibilitySourceMatcher, + visibilitySettings, + meta ?? null + ) + if (scan.ok) { + return detectedWorktree + } + return applyMetadataFallbackVisibility(detectedWorktree) + }) + return { + repoId: repo.id, + authoritative: scan.ok, + source: scan.ok ? 'git' : 'metadata-fallback', + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) + } + } + + async teardownMissingManagedWorktreeTerminals( + repoSelector: string, + knownWorktreeIds: readonly string[], + connectionId?: string | null + ): Promise<{ stoppedWorktreeIds: string[] }> { + const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + // Why: killing PTYs must be proven against the host right now — a cached scan + // (30s TTL) can still list a directory git already dropped, and the renderer + // purges its state either way, so a stale miss strands those processes for good. + this.invalidateWorktreeScanCacheForRepo(repo.id) + // Why: rescanning by `id:` would re-resolve the already-resolved repo, and a + // duplicate id across hosts makes that second lookup throw selector_ambiguous + // even though the caller's selector was unique — losing the sweep entirely. + const detected = await this.listDetectedWorktreesForResolvedRepo(repo) + if (!detected.authoritative) { + return { stoppedWorktreeIds: [] } + } + return stopMissingWorktreeTerminals( + repo, + knownWorktreeIds, + detected.worktrees.map((worktree) => worktree.id), + { + runtime: this, + getLocalProvider: () => this.getLocalProvider(), + getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId), + onPtyStopped: this.onPtyStopped ?? undefined + } + ) + } + + private resolveRepoSelectorForConnection( + repoSelector: string, + connectionId?: string | null + ): Promise { + if (connectionId === undefined) { + return this.resolveRepoSelector(repoSelector) + } + // Why: an explicit connection identity only *narrows* the selector; it must not + // change the grammar. Matching the selector as a bare repo id would make + // `path:`/`name:` selectors resolve to repo_not_found on this path alone. + const wanted = connectionId?.trim() || null + const matches = this.selectReposBySelector(repoSelector).filter( + (repo) => (repo.connectionId?.trim() || null) === wanted + ) + if (matches.length !== 1) { + throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found') + } + return Promise.resolve(matches[0]) + } + + private isRuntimeWorktreeVisible( + worktree: Worktree, + worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, + settings?: ReturnType + ): boolean { + const repo = this.store?.getRepo(worktree.repoId) + if (!repo || !this.store) { + return true + } + return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher, settings) + .visible + } + + private buildRuntimeVisibilitySourceMatchersByRepoId( + worktrees: readonly Worktree[], + visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] + ): Map { + const checkoutPathsByRepoId = new Map() + for (const worktree of worktrees) { + const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] + checkoutPaths.push(worktree.path) + checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) + } + return new Map( + (this.store?.getRepos() ?? []) + .filter((repo) => checkoutPathsByRepoId.has(repo.id)) + .map((repo) => [ + repo.id, + createWorktreeVisibilitySourceMatcher( + [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], + resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + ]) + ) + } + + private toRuntimeDetectedWorktree( + repo: Repo, + worktree: Worktree, + worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, + providedSettings?: ReturnType, + providedMeta?: WorktreeMeta | null + ): DetectedWorktree { + const settings = providedSettings ?? this.store?.getSettings() + if (!settings) { + return { + ...worktree, + ownership: 'unknown-legacy', + selectedCheckout: false, + visible: true + } + } + return toDetectedWorktree({ + repo, + worktree, + meta: + providedMeta === undefined + ? this.store?.getWorktreeMeta(worktree.id) + : (providedMeta ?? undefined), + settings, + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), + isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo), + worktreeVisibilitySourceMatcher + }) + } + + async showManagedWorktree(worktreeSelector: string) { + return await this.resolveWorktreeSelector(worktreeSelector) + } + + async showManagedTerminalWorkspace(worktreeSelector: string) { + const target = await this.resolveTerminalWorkspaceLaunchTarget(worktreeSelector) + if (!target.managedWorktree) { + throw new Error('selector_not_found') + } + return target.managedWorktree + } + + async scanWorkspacePorts(repoId?: string): Promise { + return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) + } + + async killWorkspacePort(args: WorkspacePortKillRequest): Promise { + return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args) + } + + // Why: remote clients may invoke this over RPC, so the runtime derives + // allowed worktree paths from its own store instead of trusting client paths. + private async getWorkspacePortProbes(repoId?: string): Promise { + const reposById = new Map( + this.requireStore() + .getRepos() + .map((repo) => [repo.id, repo]) + ) + return filterWorkspacePortProbes( + (await this.listResolvedWorktrees()).map((worktree) => ({ + id: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.git.path, + connectionId: reposById.get(worktree.repoId)?.connectionId ?? null + })), + repoId + ) + } + + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + // Why: sleep is renderer-initiated on desktop (it tears down tab state + // before killing PTYs). The notifier tells the renderer to run its own + // sleep flow so all cleanup happens in the correct order. + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } + } + + async activateManagedWorktree( + worktreeSelector: string, + opts: { + notifyClients?: boolean + clientKind?: 'mobile' | 'runtime' + navigation?: RuntimeNavigationTarget + } = {} + ): Promise<{ + repoId: string + worktreeId: string + activated: boolean + /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no + * renderer holds the sleeping records (headless `orca serve`), so nothing + * woke — clients must not present the worktree's agents as resumed. */ + sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' + }> { + this.assertGraphReady() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') + const targetsHost = navigationTargetsHost(navigation) + const targetsClients = navigationTargetsClients(navigation) + + if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { + // Why: mobile/web session activation intentionally bypasses renderer + // selection, so the runtime must acknowledge the unread state itself. + this.store.setWorktreeMeta(worktree.id, { isUnread: false }) + this.notifyWorktreesChanged(repo.id) + } + + let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = + 'not-applicable' + if (targetsHost || targetsClients) { + // Why: inactive worktree terminal panes are renderer-owned and may not have + // live PTYs until the desktop activates the worktree and mounts them. + if (targetsHost) { + this.notifyHostActivateWorktree(repo.id, worktree.id) + } + if (targetsClients) { + this.notifyClientsActivateWorktree(repo.id, worktree.id) + } + } + if (!targetsHost) { + // Why: mobile/web selection needs fresh session surfaces without forcing + // every attached desktop renderer to navigate to the phone's workspace. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { + allowAttachedWindow: true + }) + await this.refreshMobileSessionPtyRecords() + this.notifyMobileSessionTabsChanged(worktree.id) + // Why: a phone open must also wake the worktree's slept agents (experimental + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected. Headless serve has no renderer to wake anything, so report + // that explicitly instead of letting mobile assume the agents resumed. + if (opts.clientKind === 'mobile') { + if (this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + sleepingAgentWake = 'requested' + } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. + Object.values( + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} + ).some((record) => record.worktreeId === worktree.id) + ) { + // Why: headless is only degraded when this worktree actually has a + // persisted resume record. Ordinary mobile activation must not show + // an unsupported warning merely because no desktop window is open. + sleepingAgentWake = 'unsupported-headless' + } + } + } + return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } + } + + private async buildStartupForDraft( + repo: Repo, + draft: string, + requestedAgent?: TuiAgent + ): Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + draftPaste?: WorktreeStartupDraftPaste + } | null> { + if (!this.store) { + return null + } + const content = draft.trim() + if (!content) { + return null + } + const settings = this.store.getSettings() + const preferredAgent = requestedAgent ?? settings.defaultTuiAgent + if (preferredAgent === 'blank') { + // Why: `blank` is an explicit user preference to create a shell-only + // workspace, so linked task drafts must not auto-pick a detected agent. + return null + } + let agent = + isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents) + ? preferredAgent + : null + if (!agent) { + let detected: string[] = [] + try { + // Why: startup-draft fallback can run from sparse runtime launch envs too. + detected = repo.connectionId + ? await detectRemoteAgents({ connectionId: repo.connectionId }) + : await detectInstalledAgentsWithShellPathHydration() + } catch { + detected = [] + } + const typedDetected = detected.filter(isTuiAgent) + agent = pickTuiAgent(null, typedDetected, settings.disabledTuiAgents) + } + if (!agent) { + return null + } + + // Why: a mobile client can run on Windows while the workspace shell is + // Linux over SSH. Startup command quoting must target the shell that runs it. + const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) + const isRemote = repoIsRemote(repo) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform: agentLaunchPlatform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const draftLaunchPlan = buildAgentDraftLaunchPlan({ + agent, + draft: content, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + platform: agentLaunchPlatform, + shell: queuedShell, + isRemote + }) + if (draftLaunchPlan) { + return { + agent, + startup: { + command: draftLaunchPlan.launchCommand, + launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), + ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) + } + } + } + + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + platform: agentLaunchPlatform, + shell: queuedShell, + isRemote, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + return null + } + return { + agent, + startup: { + command: startupPlan.launchCommand, + launchConfig: startupPlan.launchConfig, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + ...(startupPlan.env ? { env: startupPlan.env } : {}) + }, + draftPaste: { agent, content } + } + } + + private buildStartupForAgent( + repo: Repo, + agent: TuiAgent, + prompt: string | undefined, + launchPreferences?: AgentLaunchPreferences + ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + // Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for + // the workspace shell rather than the client shell. + const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) + const isRemote = repoIsRemote(repo) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform: agentLaunchPlatform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const sessionOptions = this.toAgentSessionOptions(launchPreferences) + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: prompt ?? '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), + platform: agentLaunchPlatform, + shell: queuedShell, + isRemote, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + throw new Error(`Could not build launch command for ${agent}.`) + } + return { + agent, + startup: { + command: startupPlan.launchCommand, + launchConfig: startupPlan.launchConfig, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + ...(startupPlan.env ? { env: startupPlan.env } : {}) + }, + ...(startupPlan.followupPrompt + ? { + followup: { + expectedProcess: startupPlan.expectedProcess, + prompt: startupPlan.followupPrompt + } + } + : {}) + } + } + + private markWorkspaceTrustedForAgent( + agent: TuiAgent, + connectionId: string | null | undefined, + workspacePath: string + ): Promise { + return connectionId + ? this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath) + : this.markLocalWorkspaceTrustedForAgent(agent, workspacePath) + } + + private async markLocalWorkspaceTrustedForAgent( + agent: TuiAgent, + workspacePath: string + ): Promise { + const preset = TUI_AGENT_CONFIG[agent].preflightTrust + if (!preset) { + return + } + try { + if (preset === 'cursor') { + markCursorWorkspaceTrusted(workspacePath) + } else if (preset === 'copilot') { + markCopilotFolderTrusted(workspacePath) + } else if (preset === 'codex') { + // Why: the Codex write queues behind any in-flight hook grant, so the + // agent must not launch until it has actually landed. + await markCodexProjectTrusted(workspacePath) + } + } catch { + // Best-effort: the user can still accept the agent trust prompt manually. + } + } + + private async markRemoteWorkspaceTrustedForAgent( + agent: TuiAgent, + connectionId: string, + workspacePath: string + ): Promise { + const preset = TUI_AGENT_CONFIG[agent].preflightTrust + if (!preset) { + return + } + try { + await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) + } catch { + // Best-effort: the user can still accept the remote agent trust prompt manually. + } + } + + private recordCreatedWorktreeLineage( + worktree: Pick, + lineageResolution: WorktreeLineageResolution + ): { + lineage: WorktreeLineage | null + workspaceLineage: WorkspaceLineage | null + warnings: WorktreeLineageWarning[] + } { + const warnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] + let lineage: WorktreeLineage | null = null + let workspaceLineage: WorkspaceLineage | null = null + if (lineageResolution.kind !== 'lineage') { + return { lineage, workspaceLineage, warnings } + } + + const childInstanceId = worktree.instanceId + const parentInstanceId = lineageResolution.parent.instanceId + const createdAt = Date.now() + if ( + lineageResolution.parent.type === 'worktree' && + childInstanceId && + parentInstanceId && + this.store?.setWorktreeLineage + ) { + lineage = this.store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: childInstanceId, + parentWorktreeId: lineageResolution.parent.worktree.id, + parentWorktreeInstanceId: parentInstanceId, + origin: lineageResolution.origin, + capture: lineageResolution.capture, + ...(lineageResolution.orchestrationRunId + ? { orchestrationRunId: lineageResolution.orchestrationRunId } + : {}), + ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), + ...(lineageResolution.coordinatorHandle + ? { coordinatorHandle: lineageResolution.coordinatorHandle } + : {}), + ...(lineageResolution.createdByTerminalHandle + ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } + : {}), + createdAt + }) + } else if (lineageResolution.parent.type === 'worktree') { + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', + details: { + childHasInstanceId: Boolean(childInstanceId), + parentHasInstanceId: Boolean(parentInstanceId), + storeSupportsLineage: Boolean(this.store?.setWorktreeLineage) + } + }) + } + if (childInstanceId && this.store?.setWorkspaceLineage) { + workspaceLineage = this.store.setWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + childInstanceId, + parentWorkspaceKey: lineageResolution.parent.workspaceKey, + parentInstanceId, + origin: lineageResolution.origin, + capture: lineageResolution.capture, + ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), + ...(lineageResolution.orchestrationRunId + ? { orchestrationRunId: lineageResolution.orchestrationRunId } + : {}), + ...(lineageResolution.coordinatorHandle + ? { coordinatorHandle: lineageResolution.coordinatorHandle } + : {}), + ...(lineageResolution.createdByTerminalHandle + ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } + : {}), + createdAt + }) + } + return { lineage, workspaceLineage, warnings } + } + + private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { + void this.waitForStartupDraftReady(handle, draft.agent) + .then((ptyId) => { + if (!ptyId) { + console.warn('[worktree-create] agent did not become ready for draft paste') + return + } + this.ptyController?.write( + ptyId, + `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` + ) + }) + .catch((error) => { + console.warn('[worktree-create] failed to paste startup draft:', error) + }) + } + + private sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { + void this.waitForStartupFollowupReady(handle, followup.expectedProcess) + .then((ptyId) => { + if (!ptyId) { + console.warn('[worktree-create] agent did not become ready for follow-up prompt') + return + } + this.ptyController?.write(ptyId, `${followup.prompt}\r`) + }) + .catch((error) => { + console.warn('[worktree-create] failed to send startup follow-up prompt:', error) + }) + } + + private async createDefaultTabTerminals( + worktreeSelector: string, + worktreeId: string, + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, + surfacing: { surfaceOwner?: false } = {} + ): Promise { + if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { + return [] + } + const handles: string[] = [] + for (const template of defaultTabs.tabs) { + try { + const command = template.command?.trim() + const terminal = await this.createTerminal(worktreeSelector, { + ...(template.title ? { title: template.title } : {}), + ...(command && defaultTabs.runCommands ? { command } : {}), + ...surfacing + }) + handles.push(terminal.handle) + if (template.color && terminal.tabId) { + await this.setMobileSessionTabProps(`id:${worktreeId}`, { + tabId: terminal.tabId, + color: template.color + }) + } + } catch (error) { + console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) + } + } + return handles + } + + private async provisionManagedWorktreeTerminals(args: { + worktreeSelector: string + worktreeId: string + worktreePath: string + setup?: CreateWorktreeResult['setup'] + defaultTabs?: CreateWorktreeResult['defaultTabs'] + primaryTerminalHandle?: string | null + hasStartupTerminal: boolean + setupCommandPlatform: 'windows' | 'posix' + observeSetupCompletion?: boolean + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + // Why: a workspace provisioned in the background must not pull the sidebar + // to itself; the user never asked to look at these tabs. + surfaceOwner?: false + }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { + if (!this.ptyController?.spawn) { + return { setupSpawned: false, setupTerminalHandle: null } + } + const surfacing = ownerSurfacing(args.surfaceOwner !== false) + let setupSpawned = false + let setupTerminalHandle: string | null = null + try { + const defaultTabHandles = await this.createDefaultTabTerminals( + args.worktreeSelector, + args.worktreeId, + args.defaultTabs, + surfacing + ) + let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null + const setupLaunchMode = + ( + this.requireStore().getSettings() as Partial< + Pick + > + ).setupScriptLaunchMode ?? 'new-tab' + if (!args.hasStartupTerminal && !primaryTerminalHandle) { + const terminal = await this.createTerminal(args.worktreeSelector, surfacing) + primaryTerminalHandle = terminal.handle + } + if (args.setup) { + const completionToken = + args.observeSetupCompletion && !args.wrappedSetupCommand ? randomUUID() : null + const observedCommand = completionToken + ? buildObservedSetupCommand( + args.setup.runnerScriptPath, + args.setupCommandPlatform, + completionToken, + args.setup.shell + ) + : null + const setupCommand = + args.wrappedSetupCommand ?? + observedCommand?.command ?? + buildSetupRunnerCommand( + args.setup.runnerScriptPath, + args.setupCommandPlatform, + args.setup.shell + ) + const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } + const shouldSplitSetup = + primaryTerminalHandle && + (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') + const setupTerminal = await (shouldSplitSetup + ? this.splitTerminal(primaryTerminalHandle!, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: setupEnv, + activate: false, + ...surfacing + }) + : this.createTerminal(args.worktreeSelector, { + title: 'Setup', + command: setupCommand, + env: setupEnv, + ...surfacing + })) + setupTerminalHandle = setupTerminal.handle + setupSpawned = true + const ptyId = this.getLivePtyForHandle(setupTerminal.handle)?.pty.ptyId + if (completionToken && ptyId) { + this.setupCompletionTokenByPtyId.set(ptyId, completionToken) + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn( + `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` + ) + } + return { setupSpawned, setupTerminalHandle } + } + + private async waitForStartupFollowupReady( + handle: string, + expectedProcess: string + ): Promise { + const livePty = this.getLivePtyForHandle(handle) + const ptyId = livePty?.pty.ptyId + if (!ptyId || !this.ptyController) { + return null + } + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 150)) + } + try { + const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) + if (isExpectedAgentProcess(foregroundProcess, expectedProcess)) { + return ptyId + } + if (attempt >= 4 && !isShellProcess(foregroundProcess ?? '')) { + const hasChildProcesses = + (await this.ptyController.hasChildProcesses?.(ptyId).catch(() => false)) ?? false + if (hasChildProcesses) { + return ptyId + } + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + } + return null + } + + private waitForStartupDraftReady(handle: string, agent: TuiAgent): Promise { + const livePty = this.getLivePtyForHandle(handle) + const ptyId = livePty?.pty.ptyId + if (!ptyId) { + return Promise.resolve(null) + } + const readySignal = + TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + return new Promise((resolve) => { + let settled = false + const scanner = createDraftPasteReadyScanner(readySignal) + let quietTimer: NodeJS.Timeout | null = null + let hardTimer: NodeJS.Timeout | null = null + let unsubscribe: (() => void) | null = null + + const finish = (value: string | null): void => { + if (settled) { + return + } + settled = true + if (quietTimer) { + clearTimeout(quietTimer) + } + if (hardTimer) { + clearTimeout(hardTimer) + } + unsubscribe?.() + resolve(value) + } + + const armQuietTimer = (): void => { + if (quietTimer) { + clearTimeout(quietTimer) + } + quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) + } + + const observeData = (data: string): void => { + const { ready, armQuietTimer: shouldArm } = scanner.observe(data) + if (ready) { + finish(ptyId) + return + } + if (shouldArm) { + armQuietTimer() + } + } + + unsubscribe = this.subscribeToTerminalData(ptyId, observeData) + const replay = this.recentPtyOutputById.get(ptyId)?.read() + if (replay) { + observeData(replay) + } + hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent)) + }) + } + + async prefetchManagedWorktreeCreateBase(args: { + repoSelector: string + baseBranch?: string + }): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + const repo = await this.resolveRepoSelector(args.repoSelector) + const baseBranch = await prefetchWorktreeCreateBase({ + repo, + baseBranch: args.baseBranch, + runtime: this + }) + if (baseBranch) { + try { + await prepareWorktreeCreateForRepo(this.requireStore(), repo, baseBranch) + } catch { + // Why: speculative preparation is an optimistic warm-up; the real create path reports failures. + } + } + } + + async createManagedWorktree(args: { + repoSelector: string + name: string + /** True only when `name` came from Orca's creature-name generator; gates retirement so a name + * the user typed stays reusable. Absent for CLI and automation callers. */ + nameWasGenerated?: boolean + baseBranch?: string + compareBaseRef?: string + branchNameOverride?: string + linkedIssue?: number | null + linkedPR?: number | null + linkedLinearIssue?: string + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + linkedGitLabMR?: number | null + linkedGitLabIssue?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + comment?: string + displayName?: string + telemetrySource?: WorkspaceCreateTelemetrySource + workspaceStatus?: string + manualOrder?: number + sparseCheckout?: { directories: string[]; presetId?: string } + pushTarget?: GitPushTarget + runHooks?: boolean + activate?: boolean + /** Who the create's activation is addressed to. Defaults to 'all' so host/CLI callers keep + * revealing on every surface; the RPC layer narrows it to 'caller' for paired clients. */ + navigation?: RuntimeNavigationTarget + setupDecision?: 'run' | 'skip' | 'inherit' + awaitTerminalProvisioning?: boolean + observeSetupCompletion?: boolean + createdWithAgent?: TuiAgent + startupAgent?: TuiAgent + startupLaunchPreferences?: AgentLaunchPreferences + startupPrompt?: string + pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance + creatorProvenance?: Worktree['creatorProvenance'] + startup?: WorktreeStartupLaunch + startupDraft?: string + startupDraftPaste?: WorktreeStartupDraftPaste + lineage?: WorktreeLineageInput + }): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + const repo = await this.resolveRepoSelector(args.repoSelector) + const createSettings = this.store.getSettings() + const requestedAgent = args.startupAgent ?? args.createdWithAgent + const requestedAgentEnabled = + requestedAgent !== undefined + ? isTuiAgentEnabled(requestedAgent, createSettings.disabledTuiAgents) + : false + if ((args.startup || args.startupAgent) && requestedAgent && !requestedAgentEnabled) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + if ( + args.startup && + args.startupDraftPaste && + !isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents) + ) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + const agentStartup = + !args.startup && args.startupAgent + ? this.buildStartupForAgent( + repo, + args.startupAgent, + args.startupPrompt, + args.startupLaunchPreferences + ) + : null + const draftStartup = + !args.startup && !agentStartup && args.startupDraft + ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) + : null + const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup + const effectiveStartupFollowup = agentStartup?.followup + const effectiveCreatedWithAgent = args.startup + ? args.createdWithAgent + : (agentStartup?.agent ?? + draftStartup?.agent ?? + (requestedAgentEnabled ? requestedAgent : undefined)) + const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste + if (isFolderRepo(repo)) { + const now = Date.now() + const settings = createSettings + const instanceId = randomUUID() + const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) + const meta = this.store.setWorktreeMeta(worktreeId, { + instanceId, + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), + displayName: args.displayName?.trim() || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: { + path: settings.workspaceDir, + nestWorkspaces: settings.nestWorkspaces + }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined + ? { linkedLinearIssue: args.linkedLinearIssue } + : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.linkedGitLabIssue !== undefined + ? { linkedGitLabIssue: args.linkedGitLabIssue } + : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined + ? { linkedBitbucketPR: args.linkedBitbucketPR } + : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(args.comment !== undefined ? { comment: args.comment } : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) + }) + const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) + this.invalidateResolvedWorktreeCache() + this.notifyWorktreesChanged(repo.id) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) + const shouldActivate = args.activate === true || args.runHooks === true + let warning: string | undefined + let didSpawnStartup = false + let startupTerminal: CreateWorktreeResult['startupTerminal'] + if (effectiveStartup && this.ptyController?.spawn) { + try { + const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent + if (startupTrustAgent) { + await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) + } + const terminal = await this.createTerminal(`id:${worktree.id}`, { + command: effectiveStartup.command, + env: effectiveStartup.env, + ...(effectiveStartup.launchConfig + ? { launchConfig: effectiveStartup.launchConfig } + : {}), + ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), + startupCommandDelivery: effectiveStartup.startupCommandDelivery, + telemetry: effectiveStartup.telemetry, + ...ownerSurfacing(shouldActivate) + }) + if (effectiveDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) + } + if (effectiveStartupFollowup) { + this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) + } + didSpawnStartup = true + startupTerminal = { + spawned: true, + handle: terminal.handle, + ...(terminal.tabId ? { tabId: terminal.tabId } : {}), + ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), + ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), + surface: 'background' + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + if (shouldActivate) { + if (effectiveStartup && !didSpawnStartup) { + this.notifyActivateWorktree(repo.id, worktree.id, { + startup: effectiveStartup, + navigationTarget: args.navigation + }) + } else { + this.notifyActivateWorktree(repo.id, worktree.id, { + navigationTarget: args.navigation + }) + } + } else if (this.ptyController?.spawn && !didSpawnStartup) { + try { + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}` + : `Failed to create the initial terminal for ${worktree.path}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + return { + worktree: { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + }, + ...(startupTerminal ? { startupTerminal } : {}), + ...(warning ? { warning } : {}) + } + } + const lineageInput = + args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined + const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) + if (repo.connectionId) { + const result = await this.createManagedRemoteWorktree(repo, { + ...args, + activate: args.activate, + ...(effectiveStartup ? { startup: effectiveStartup } : {}), + ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + }) + const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return { + ...result, + worktree: { + ...result.worktree, + parentWorktreeId: recordedLineage.lineage?.parentWorktreeId ?? null, + childWorktreeIds: result.worktree.childWorktreeIds ?? [], + lineage: recordedLineage.lineage, + workspaceLineage: recordedLineage.workspaceLineage + }, + ...(lineageInput + ? { + lineage: recordedLineage.lineage, + workspaceLineage: recordedLineage.workspaceLineage, + warnings: recordedLineage.warnings + } + : {}) + } + } + const settings = createSettings + const worktreePathSettings = getWorktreePathSettings( + repo, + settings, + getWorktreeMirrorDistro(this.requireStore(), repo) + ) + const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const hasLocalWorktreeGitOptions = hasLocalGitOptions(localWorktreeGitOptions) + const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions + ? [localWorktreeGitOptions] + : [] + const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined => { + if (!hasLocalWorktreeGitOptions) { + return options + } + return { ...options, ...localWorktreeGitOptions } + } + const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo) + let effectiveRequestedName = args.name + const requestedDisplayName = args.displayName?.trim() || undefined + const sanitizedName = sanitizeWorktreeName(args.name) + let effectiveSanitizedName = sanitizedName + // Username and base resolution are independent read-only probes. Starting + // both before awaiting removes one serial git/config round trip from create. + const usernamePromise = + !args.branchNameOverride && settings.branchPrefix === 'git-username' + ? resolveLocalGitUsername(repo.path) + : Promise.resolve('') + const baseBranchPromise = resolveWorktreeCreateBase({ + requestedBaseBranch: args.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + hasLocalWorktreeGitOptions + ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) + : getBaseRefDefault(repo.path), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + if ( + await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + ) { + return true + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + }) + const [username, baseBranch] = await Promise.all([usernamePromise, baseBranchPromise]) + if (!baseBranch) { + // Why: a null default means no suitable ref exists; fail clearly instead + // of handing Git a fabricated origin/main ref. + throw new Error( + 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' + ) + } + + const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) + // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the + // distro filesystem through computeWorkspaceRoot. If home lookup fails, + // still validate against the effective workspace dir. + let branchName = '' + let checkoutExistingBranch = false + let selectedExistingLocalBranchName: string | null = null + let branchConflictKind: 'local' | 'remote' | null = null + let worktreePath = '' + let worktreePathResolved = false + const shouldRetireGeneratedName = + args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) + const retiredNameRegistry = shouldRetireGeneratedName + ? await getRetiredNameRegistryForRepo(this.store, repo, this.store.getRepos(), settings) + : null + const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null + // Why: runtime/mobile create-from-review callers should get a new workspace + // even when the PR branch or review branch name is already in use. + for ( + let suffix = 1, attempts = 0; + attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; + suffix += 1 + ) { + effectiveSanitizedName = shouldRetireGeneratedName + ? getGeneratedWorktreeCreateCandidate( + sanitizedName, + suffix, + retiredNameRegistry?.exhaustedTiers + ) + : getWorktreeCreateCandidate(sanitizedName, suffix) + effectiveRequestedName = shouldRetireGeneratedName + ? effectiveSanitizedName + : args.name.trim() + ? getWorktreeCreateCandidate(args.name, suffix) + : effectiveSanitizedName + if (isRetiredName?.(effectiveSanitizedName)) { + continue + } + attempts += 1 + branchName = await resolveCreateBranchName( + repo.path, + selectedExistingLocalBranchName ?? + getBranchNameOverrideCandidate(args.branchNameOverride, suffix), + effectiveSanitizedName, + settings, + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + ...localWorktreeGitOptionArgs + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Why: once a user-selected branch is safe to reuse, path retries should + // keep that branch exact instead of creating a sibling branch. + selectedExistingLocalBranchName = branchName + } + branchConflictKind = checkoutExistingBranch + ? null + : await getBranchConflictKind( + repo.path, + branchName, + baseBranch, + ...localWorktreeGitOptionArgs + ) + const allowedPushTargetRemoteConflict = + branchConflictKind && + isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args) + let selectedReviewConflictMatched = false + if (branchConflictKind) { + if (allowedPushTargetRemoteConflict) { + let existingPR: Awaited> | null = null + const selectedReview = getSelectedReviewBranch(args) + if (selectedReview?.provider === 'github') { + try { + existingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // Retry with a suffixed branch when selected review verification is unavailable. + } + if (isMatchingSelectedGitHubPr(existingPR, args, branchName)) { + branchConflictKind = null + selectedReviewConflictMatched = true + } + } else if (selectedReview) { + const hostedReview = await getSelectedHostedReviewForBranch( + repo, + branchName, + args, + hostedReviewExecutionContext + ).catch(() => null) + if (hostedReview?.matchesSelected) { + branchConflictKind = null + selectedReviewConflictMatched = true + } + } + } + if (branchConflictKind) { + continue + } + } + + if (!checkoutExistingBranch && !selectedReviewConflictMatched) { + let existingPR: Awaited> | null = null + try { + existingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // Why: GitHub reachability should not block creating a suffixed + // workspace; git conflicts still decide whether this candidate works. + } + if (existingPR && !isMatchingSelectedGitHubPr(existingPR, args, branchName)) { + continue + } + } + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (!(await pathExists(worktreePath))) { + worktreePathResolved = true + break + } + } + if (!worktreePathResolved) { + if (branchConflictKind) { + throw new Error( + `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` + ) + } + throw new Error( + `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` + ) + } + let remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + baseBranch, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + const [hadRemoteTrackingBaseRef, hasNamedLocalBaseRef] = await Promise.all([ + this.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs), + hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + ]) + const hasLocalBaseRef = hadRemoteTrackingBaseRef || hasNamedLocalBaseRef + if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null + } else { + const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + if (!refreshResult.ok && !hadRemoteTrackingBaseRef) { + // Why: only block creation when the refresh failed AND there is no + // usable local base ref to fall back on. If a local remote-tracking ref + // already exists, `git worktree add` can create from it — a possibly + // stale but valid base — so a transient offline/auth failure must not + // make the workspace uncreatable. The compare-to-base view reflects any + // drift once the remote is reachable again. + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + if ( + !hadRemoteTrackingBaseRef && + !(await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + )) + ) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } + } + } else if ( + !(await hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + )) + ) { + // Why: local bases keep legacy best-effort fetch behavior. Verified PR + // SHA bases already have the commit object needed by `git worktree add`. + try { + await this.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs) + } catch { + // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does + // not throw; the outer try/catch guarantees create-path tolerance even + // if future refactors change that contract. + } + } + + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + + let preparedPushTarget: GitPushTarget | undefined + if (args.pushTarget) { + // Why: fork-PR worktrees created through a remote runtime need the same + // upstream target setup as local desktop creates, or Push would publish + // to the wrong remote after the client/server split. + preparedPushTarget = await prepareWorktreePushTarget( + repo.path, + args.pushTarget, + this.store, + repo.id, + localWorktreeGitOptions + ) + } + + const suggestLocalBaseRefUpdate = + !settings.refreshLocalBaseRefOnWorktreeCreate && + !settings.localBaseRefSuggestionDismissed && + Boolean(remoteTrackingBase) + const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined + const existingBranchOption = { + checkoutExistingBranch, + ...remoteTrackingBaseOption, + ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) + } + const defaultAddWorktreeOption = addProjectGitOptions() + const preparedWorktreeOptions = suggestLocalBaseRefUpdate + ? addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) + : remoteTrackingBaseOption + ? addProjectGitOptions(remoteTrackingBaseOption) + : defaultAddWorktreeOption + let addResult: AddWorktreeResult + try { + const preparedResult = + sparseDirectories.length === 0 && !checkoutExistingBranch + ? await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot, + worktreePath, + branch: branchName, + baseBranch, + refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, + ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) + }) + : null + addResult = + preparedResult ?? + (await (sparseDirectories.length > 0 + ? checkoutExistingBranch + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + addProjectGitOptions(existingBranchOption) + ) + : suggestLocalBaseRefUpdate + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) + ) + : remoteTrackingBaseOption + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + addProjectGitOptions(remoteTrackingBaseOption) + ) + : defaultAddWorktreeOption + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + defaultAddWorktreeOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : checkoutExistingBranch + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + addProjectGitOptions(existingBranchOption) + ) + : suggestLocalBaseRefUpdate + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) + ) + : remoteTrackingBaseOption + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + addProjectGitOptions(remoteTrackingBaseOption) + ) + : defaultAddWorktreeOption + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + defaultAddWorktreeOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ))) ?? + {} + } catch (error) { + if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) { + await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName) + } + throw error + } + + // Why: fallible metadata work after creation must not leave a real workspace name reusable. + if (shouldRetireGeneratedName) { + await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName) + } + + let configuredPushTarget: GitPushTarget | undefined + if (preparedPushTarget) { + configuredPushTarget = await configureCreatedWorktreePushTarget( + worktreePath, + branchName, + preparedPushTarget, + localWorktreeGitOptions + ) + } + + const { created } = await resolveCreatedWorktree( + repo.path, + worktreePath, + branchName, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined + ) + + const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() + // Why: PR/MR-created worktrees can start from a head ref/SHA while Source + // Control must compare against the review target branch. + const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch + const displayNameMeta = requestedDisplayName + ? { displayName: requestedDisplayName } + : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) + ? { displayName: effectiveRequestedName } + : {} + const meta = this.store.setWorktreeMeta(worktreeId, { + // Why: worktree IDs are path-derived. If a path is deleted outside Orca + // and later recreated, creation must mint a fresh instance identity so + // stale lineage records tied to the old occupant fail validation. + instanceId: randomUUID(), + ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), + lastActivityAt: now, + // See createRemoteWorktree: createdAt grants the new worktree a grace + // window in Recent sort so ambient PTY bumps in OTHER worktrees can't + // push it down before the user has had a chance to notice it. Smart-sort + // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...displayNameMeta, + baseRef: metadataBaseRef, + ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), + ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: metadataBaseRef, + sparsePresetId: args.sparseCheckout?.presetId + } + : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined + ? { linkedLinearIssue: args.linkedLinearIssue } + : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.linkedGitLabIssue !== undefined + ? { linkedGitLabIssue: args.linkedGitLabIssue } + : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined + ? { linkedBitbucketPR: args.linkedBitbucketPR } + : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, + ...(args.comment !== undefined ? { comment: args.comment } : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) + }) + const worktree = { + ...mergeWorktree(repo.id, created, meta), + hostId: meta.hostId ?? getRepoExecutionHostId(repo) + } + const { + lineage, + workspaceLineage, + warnings: lineageWarnings + } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) + + const symlinkPaths = repo.symlinkPaths ?? [] + if (symlinkPaths.length > 0) { + await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) + } + + // Why: these discoveries are read-only; overlap them, but keep the + // shared-path mutation ahead of include copies below. + const [sharedDirectories, worktreeIncludePaths] = await Promise.all([ + resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions), + resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + ]) + if (sharedDirectories.length > 0) { + await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + } + + // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics + // (never symlink) so each worktree owns its files. Paths already linked above are skipped. + let includeCopyWarning: string | undefined + if (worktreeIncludePaths.length > 0) { + const skippedIncludePaths = await createWorktreeCopiedPaths( + repo.path, + created.path, + worktreeIncludePaths + ) + includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) + if (includeCopyWarning) { + console.warn(`[worktree-include] ${includeCopyWarning}`) + } + } + + let setup: CreateWorktreeResult['setup'] + let warning: string | undefined = includeCopyWarning + // Why: CLI-created worktrees do not have a renderer preview to mismatch + // against. Trust is granted by the direct CLI invocation (`--run-hooks`), + // so loading the setup hook from the created worktree is intentional here. + const yamlHooks = loadHooks(worktreePath) + const hooks = getEffectiveHooks(repo, worktreePath) + // Why: setupDecision lets mobile/CLI callers control whether the setup + // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) + // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps + // to 'run' for backwards compatibility with the desktop create flow. + const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + let defaultTabs: CreateWorktreeResult['defaultTabs'] + try { + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) + } catch (error) { + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined + } + const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + // Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run + // when nothing downstream is able to launch the shell-aware runner script. + let didStartInProcessSetupHook = false + if (shouldRunSetup && hooks?.scripts.setup) { + const shouldUseSetupRunner = + this.authoritativeWindowId !== null || + Boolean(effectiveStartup) || + Boolean(this.ptyController?.spawn) + if (shouldUseSetupRunner) { + try { + // Why: setup+startup must share the terminal runner path even without + // a renderer window, so the startup shell can wait on setup completion + // and windowless creates resolve the same Windows setup shell. + const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] + setup = createSetupRunnerScript( + repo, + worktreePath, + hooks.scripts.setup, + runtimeTarget, + resolveSetupRunnerShell(settings), + yamlHooks?.setupAgentStartupPolicy + ) + } catch (error) { + // Why: the git worktree is already real at this point. If runner + // generation fails, keep creation successful and surface the problem in + // logs rather than pretending the worktree was never created. + console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) + } + } else { + didStartInProcessSetupHook = true + void runHook( + 'setup', + worktreePath, + repo, + worktreePath, + this.getLocalGitExecutionOptionArgs(repo)[0] + ).then((result) => { + if (!result.success) { + console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) + } + }) + } + } else if (hooks?.scripts.setup && effectiveDecision !== 'skip') { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + const setupSkipped = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` + warning = warning ? `${warning} Also ${setupSkipped}` : setupSkipped + console.warn(`[hooks] ${setupSkipped}`) + } + + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + // Why: the filesystem-auth layer maintains a separate cache of registered + // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) + // to authorize paths. Without invalidating it here, CLI-created worktrees + // are not recognized and all git operations fail with "Access denied: + // unknown repository or worktree path". + invalidateAuthorizedRootsCache() + + this.notifyWorktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true + let didSpawnStartup = false + // Why: tracks whether runtime itself launched the setup script (via + // provisionManagedWorktreeTerminals). When true, renderer activation and the + // RPC return value must omit setup so the client does not spawn it a second + // time. Mirrors the wait-for-agent setup contract from #6298. + let didSpawnSetup = false + let setupTerminalHandle: string | null = null + let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null + let startupTerminalPaneKey: string | null = null + let startupTerminalPtyId: string | null = null + + let sequencedStartup = effectiveStartup + let wrappedSetupCommandStr: string | undefined + if (effectiveStartup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForLaunch( + setup, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: effectiveStartup.command, + platform, + shell: setup.shell + }) + sequencedStartup = { + ...effectiveStartup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv + ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } + : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { + try { + // Why: automation startup must not depend on a renderer TerminalPane + // mounting. Runtime-spawned PTYs run immediately and the UI adopts the + // session later, matching `orca terminal create` background semantics. + const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent + if (startupTrustAgent) { + await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) + } + const terminal = await this.createTerminal(`id:${worktree.id}`, { + command: sequencedStartup.command, + ...(setup && effectiveStartup + ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) + }) + if (effectiveDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) + } + if (effectiveStartupFollowup) { + this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) + } + didSpawnStartup = true + startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null + startupTerminalPaneKey = terminal.paneKey ?? null + startupTerminalPtyId = terminal.ptyId ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the startup terminal for ${worktreePath}: ${message}` + : `Failed to create the startup terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + if (shouldActivate) { + // Why: plain CLI creates should not steal the user's current workspace. + // Explicit activation and hook-running still use renderer activation so + // the user can watch prompts/output in a visible pane. + const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: once runtime spawned the startup PTY, renderer activation may see + // an existing terminal and skip setup/default tabs. Await provisioning so + // a failed setup spawn falls back to renderer activation (which still + // carries the wrapped command for retry); #6298's wait-for-setup + // guarantee is enforced by the shell marker, not by spawn timing. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), + observeSetupCompletion: args.observeSetupCompletion, + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } + // Why: when runtime spawned setup, omit it from activation. When setup + // spawn failed, fall through with the wrapped command so renderer + // activation retries it. + const activationSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs + if (effectiveStartup && !didSpawnStartup) { + this.notifyActivateWorktree(repo.id, worktree.id, { + setup: activationSetup, + startup: effectiveStartup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) + } else { + this.notifyActivateWorktree(repo.id, worktree.id, { + setup: activationSetup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) + } + } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + const provisioning = this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), + observeSetupCompletion: args.observeSetupCompletion, + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false + }) + // Why: runtime owns setup spawning here, so the RPC result must omit setup + // to keep the headless/mobile caller from launching it a second time. + if (args.awaitTerminalProvisioning) { + const provisioned = await provisioning + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } else { + void provisioning + if (setup) { + didSpawnSetup = true + } + } + } else if (this.ptyController?.spawn) { + try { + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${worktreePath}: ${message}` + : `Failed to create the initial terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + const returnedSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) + return { + worktree: { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: [], + lineage, + workspaceLineage, + git: created + }, + ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), + ...(returnedSetup ? { setup: returnedSetup } : {}), + ...(args.awaitTerminalProvisioning + ? { + setupReceipt: { + requested: effectiveDecision, + hookFound: Boolean(hooks?.scripts.setup), + startupPolicy: setup?.waitForAgentStartup + ? ('wait-for-setup' as const) + : ('start-immediately' as const), + state: !hooks?.scripts.setup + ? ('not_configured' as const) + : effectiveDecision === 'skip' || !shouldRunSetup + ? ('skipped' as const) + : // Why: the in-process hook is already executing, so reporting + // spawn_failed would strand callers that retry on it. + didSpawnSetup || didStartInProcessSetupHook + ? ('running' as const) + : ('spawn_failed' as const), + ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) + } + } + : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(warning ? { warning } : {}), + ...(addResult.localBaseRefRefresh + ? { localBaseRefRefresh: addResult.localBaseRefRefresh } + : {}), + ...(addResult.localBaseRefUpdateSuggestion + ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } + : {}), + ...(didSpawnStartup && startupTerminalHandle + ? { + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), + ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), + surface: 'background' as const + } + } + : {}) + } + } + + private async createManagedRemoteWorktree( + repo: Repo, + args: { + name: string + nameWasGenerated?: boolean + baseBranch?: string + compareBaseRef?: string + branchNameOverride?: string + linkedIssue?: number | null + linkedPR?: number | null + linkedLinearIssue?: string + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + linkedGitLabMR?: number | null + linkedGitLabIssue?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + comment?: string + displayName?: string + workspaceStatus?: string + manualOrder?: number + sparseCheckout?: { directories: string[]; presetId?: string } + pushTarget?: GitPushTarget + runHooks?: boolean + activate?: boolean + navigation?: RuntimeNavigationTarget + setupDecision?: 'run' | 'skip' | 'inherit' + awaitTerminalProvisioning?: boolean + observeSetupCompletion?: boolean + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance + startup?: WorktreeStartupLaunch + startupFollowup?: WorktreeStartupFollowup + startupDraftPaste?: WorktreeStartupDraftPaste + } + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + // Why: runtime/mobile callers do not own a renderer BrowserWindow, but the + // SSH create helper only uses it for progress and change notifications. + // Runtime emits those through RuntimeNotifier after the create succeeds. + const headlessWindow = { + isDestroyed: () => false, + webContents: { send: () => undefined } + } as unknown as BrowserWindow + + const result = await createRemoteWorktree( + { + repoId: repo.id, + name: args.name, + ...(args.nameWasGenerated === true ? { nameWasGenerated: true } : {}), + ...(args.displayName ? { displayName: args.displayName } : {}), + ...(args.baseBranch ? { baseBranch: args.baseBranch } : {}), + ...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}), + ...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}), + ...(args.runHooks ? { setupDecision: 'run' as const } : {}), + ...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}), + ...(args.sparseCheckout ? { sparseCheckout: args.sparseCheckout } : {}), + ...(args.linkedIssue != null ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR != null ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.linkedGitLabMR != null ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedGitLabIssue != null ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedBitbucketPR != null ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR != null + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR != null ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}), + ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), + ...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.pendingFirstAgentMessageRename === true + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}) + }, + repo, + this.store as unknown as Store, + headlessWindow + ) + + if (args.comment !== undefined) { + this.store.setWorktreeMeta(result.worktree.id, { comment: args.comment }) + result.worktree.comment = args.comment + } + + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repo.id) + this.notifyWorktreesChanged(repo.id) + + const shouldActivate = args.activate === true || args.runHooks === true + let warning = result.warning + let didSpawnStartup = false + // Why: same no-double-spawn contract as the local path — once runtime + // provisions setup, omit it from activation and the RPC result. + let didSpawnSetup = false + let setupTerminalHandle: string | null = null + let startupTerminalHandle: string | null = null + let startupTerminalTabId: string | null = null + let startupTerminalPaneKey: string | null = null + let startupTerminalPtyId: string | null = null + + let sequencedStartup = args.startup + let wrappedSetupCommandStr: string | undefined + if (args.startup && result.setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: result.setup.runnerScriptPath, + startupCommand: args.startup.command, + platform, + shell: result.setup.shell + }) + sequencedStartup = { + ...args.startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { + try { + const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent + if (startupTrustAgent) { + await this.markRemoteWorkspaceTrustedForAgent( + startupTrustAgent, + repo.connectionId!, + result.worktree.path + ) + } + const terminal = await this.createTerminal(`path:${result.worktree.path}`, { + command: sequencedStartup.command, + ...(result.setup && args.startup + ? { claudeAgentTeamsSourceCommand: args.startup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) + }) + if (args.startupDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) + } + if (args.startupFollowup) { + this.sendStartupFollowupWhenReady(terminal.handle, args.startupFollowup) + } + didSpawnStartup = true + startupTerminalHandle = terminal.handle + startupTerminalTabId = terminal.tabId ?? null + startupTerminalPaneKey = terminal.paneKey ?? null + startupTerminalPtyId = terminal.ptyId ?? null + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the startup terminal for ${result.worktree.path}: ${message}` + : `Failed to create the startup terminal for ${result.worktree.path}: ${message}` + } + } + + if (shouldActivate) { + const runtimeWillProvisionTerminals = + didSpawnStartup && Boolean(result.setup || result.defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: remote/mobile task creates spawn the agent terminal in runtime, + // so renderer activation may not materialize setup/default tabs. Await so + // a failed setup spawn falls back to renderer activation for retry. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), + observeSetupCompletion: args.observeSetupCompletion, + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // remote Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } + // Why: omit setup from activation when runtime spawned it; on spawn + // failure fall through with the wrapped command so renderer retries. + const activationSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs + if (args.startup && !didSpawnStartup) { + this.notifyActivateWorktree(repo.id, result.worktree.id, { + setup: activationSetup, + startup: args.startup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) + } else { + this.notifyActivateWorktree(repo.id, result.worktree.id, { + setup: activationSetup, + defaultTabs: activationDefaultTabs, + navigationTarget: args.navigation + }) + } + } + + if ( + !shouldActivate && + this.ptyController?.spawn && + (result.setup || result.defaultTabs || didSpawnStartup) + ) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + const provisioning = this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), + observeSetupCompletion: args.observeSetupCompletion, + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false + }) + // Why: runtime owns setup spawning here, so omit setup from the RPC result + // to keep the headless/mobile caller from launching it a second time. + if (args.awaitTerminalProvisioning) { + const provisioned = await provisioning + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } else { + void provisioning + if (result.setup) { + didSpawnSetup = true + } + } + } else if (!shouldActivate && this.ptyController?.spawn) { + try { + await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${result.worktree.path}: ${message}` + : `Failed to create the initial terminal for ${result.worktree.path}: ${message}` + } + } + + const returnedSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const resultForRenderer = returnedSetup + ? { ...result, setup: returnedSetup } + : (() => { + const { setup: _setup, ...resultWithoutSetup } = result + return resultWithoutSetup + })() + + const resultWithStartupTerminal = + didSpawnStartup && startupTerminalHandle + ? { + ...resultForRenderer, + startupTerminal: { + spawned: true, + handle: startupTerminalHandle, + ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), + ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), + ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), + surface: 'background' as const + } + } + : resultForRenderer + + const requestedSetupDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const setupReceipt = { + requested: requestedSetupDecision, + hookFound: Boolean(result.setup), + startupPolicy: result.setup?.waitForAgentStartup + ? ('wait-for-setup' as const) + : ('start-immediately' as const), + state: + requestedSetupDecision === 'skip' + ? ('skipped' as const) + : !result.setup + ? ('not_configured' as const) + : didSpawnSetup + ? ('running' as const) + : ('spawn_failed' as const), + ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) + } + const resultWithSetupReceipt = args.awaitTerminalProvisioning + ? { ...resultWithStartupTerminal, setupReceipt } + : resultWithStartupTerminal + return warning ? { ...resultWithSetupReceipt, warning } : resultWithSetupReceipt + } + + /** + * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight + * serialization with all other callers. Never rejects — callers + * log-and-proceed on offline failures (§3.3 Lifecycle). + * + * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on + * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A + * dispatch tick that reuses a just-completed create-path fetch is the + * primary telemetry target; splitting the cache by call-site would double + * the fetch load on warm repos. + */ + async getCanonicalFetchKey( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + const runtimeKey = gitOptions.wslDistro ? `wsl:${gitOptions.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}::${remote}` + const cached = this.canonicalFetchKeyCache.get(cacheKey) + if (cached !== undefined) { + setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) + return cached + } + let resolved = cacheKey + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath, ...gitOptions } + ) + const commonDir = stdout.trim() + if (commonDir) { + resolved = `${runtimeKey}::${commonDir}::${remote}` + } + } catch { + // Fall through to the caller-provided path. The fetch still runs from + // repoPath; this key only controls cache sharing. + } + setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) + return resolved + } + + private enqueueRemoteFetch( + remoteKey: string, + runFetch: () => Promise + ): Promise { + const previous = this.remoteFetchQueueTail.get(remoteKey) + const promise = previous ? previous.then(runFetch, runFetch) : runFetch() + this.remoteFetchQueueTail.set(remoteKey, promise) + promise.finally(() => { + if (this.remoteFetchQueueTail.get(remoteKey) === promise) { + this.remoteFetchQueueTail.delete(remoteKey) + } + }) + return promise + } + + private getFreshFetchCompletedAt(key: string): number | null { + const lastAt = this.fetchLastCompletedAt.get(key) + if (lastAt === undefined) { + return null + } + if (Date.now() - lastAt < FETCH_FRESHNESS_MS) { + setBoundedMapEntry(this.fetchLastCompletedAt, key, lastAt, REMOTE_FETCH_CACHE_MAX) + return lastAt + } + this.fetchLastCompletedAt.delete(key) + return null + } + + private rememberFreshFetchCompletedAt(key: string, completedAt = Date.now()): void { + setBoundedMapEntry(this.fetchLastCompletedAt, key, completedAt, REMOTE_FETCH_CACHE_MAX) + } + + async getOrStartRemoteFetch( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + const key = await this.getCanonicalFetchKey(repoPath, remote, gitOptions) + if (this.getFreshFetchCompletedAt(key) !== null) { + // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any + // in-flight promise here; the timestamp is only written on success, so + // hitting this branch means a previous fetch did succeed recently. + return { ok: true } + } + + const existing = this.fetchInflight.get(key) + if (existing) { + // Why: genuine serialization (not check-then-set). Two callers racing + // on the same repo+remote share the single underlying `git fetch`. + return existing + } + + const promise = this.enqueueRemoteFetch(key, () => + gitExecFileAsync(['fetch', remote], { + cwd: repoPath, + ...gitOptions, + // Why: cap the create-path base-ref fetch so a stuck first-auth on + // Windows (GCM prompt) fails fast instead of hanging creation (STA-1292). + timeout: REMOTE_FETCH_TIMEOUT_MS + }) + .then((): RemoteFetchResult => { + // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection + // would make the freshness cache lie about the last known remote state. + this.rememberFreshFetchCompletedAt(key) + return { ok: true } + }) + .catch((err): RemoteFetchResult => { + // Why: swallow here so awaiters don't throw at the await site. Outer + // create/dispatch paths are already tolerant of offline fetch failure; + // this is the behavioral contract of this helper. + console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) + return { ok: false, errorKind: 'git_error' } + }) + ).finally(() => { + // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A + // rejected entry that survived in the Map would wedge every future + // create on this repo until Orca restarted (the F2 bug §3.3 pins). + this.fetchInflight.delete(key) + }) + + this.fetchInflight.set(key, promise) + return promise + } + + async getOrStartRemoteTrackingBaseRefresh( + repoPath: string, + base: RemoteTrackingBase, + gitOptions: { wslDistro?: string } = {} + ): Promise { + const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote, gitOptions) + const key = await this.getCanonicalFetchKey( + repoPath, + `base:${base.remote}:${base.branch}`, + gitOptions + ) + if (this.getFreshFetchCompletedAt(key) !== null) { + // Why: exact-base freshness is the safety boundary. A full remote fetch + // can be narrowed by repo refspecs, so it must not prove this branch. + return { ok: true } + } + + const existing = this.fetchInflight.get(key) + if (existing) { + return existing + } + + const promise = this.enqueueRemoteFetch(remoteKey, async () => { + if (this.getFreshFetchCompletedAt(key) !== null) { + return { ok: true } + } + // Why: this exact refresh gates worktree create; ordinary fetches still own maintenance. + return gitExecFileAsync( + [ + ...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, + 'fetch', + '--no-tags', + base.remote, + `+refs/heads/${base.branch}:${base.ref}` + ], + { + cwd: repoPath, + ...gitOptions, + // Why: exact remote-base refresh is the network gate for worktree + // creation, so honor repo SSH routing and bound custom wrappers. + useConfiguredSshCommandForNetwork: true, + timeout: REMOTE_FETCH_TIMEOUT_MS + } + ) + .then((): RemoteFetchResult => { + this.rememberFreshFetchCompletedAt(key) + return { ok: true } + }) + .catch((err): RemoteFetchResult => { + console.warn( + `[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`, + err + ) + return { ok: false, errorKind: 'git_error' } + }) + }).finally(() => { + this.fetchInflight.delete(key) + }) + + this.fetchInflight.set(key, promise) + return promise + } + + async fetchRemoteWithCache( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + await this.getOrStartRemoteFetch(repoPath, remote, gitOptions) + } + + async resolveRemoteTrackingBase( + repoPath: string, + baseBranch: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + let remotes: string[] + try { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath, ...gitOptions }) + remotes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return null + } + + const remoteRefPrefix = 'refs/remotes/' + const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix) + ? baseBranch.slice(remoteRefPrefix.length) + : baseBranch + const remote = remotes + .filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`)) + .sort((a, b) => b.length - a.length)[0] + if (!remote) { + return null + } + const branch = shortBaseBranch.slice(remote.length + 1) + if (!branch) { + return null + } + return { + remote, + branch, + ref: `refs/remotes/${remote}/${branch}`, + base: `${remote}/${branch}` + } + } + + async hasRemoteTrackingRef( + repoPath: string, + base: RemoteTrackingBase, + gitOptions: { wslDistro?: string } = {} + ): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { + cwd: repoPath, + ...gitOptions + }) + return true + } catch { + return false + } + } + + recordOptimisticReconcileToken(worktreeId: string): string { + const token = randomUUID() + this.optimisticReconcileTokens.set(worktreeId, token) + return token + } + + clearOptimisticReconcileToken(worktreeId: string): void { + this.optimisticReconcileTokens.delete(worktreeId) + } + + emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void { + this.notifier?.worktreeBaseStatus?.(event) + } + + async reconcileWorktreeBaseStatus(args: { + repoId: string + repoPath: string + worktreeId: string + base: RemoteTrackingBase + branchName: string + createdBaseSha: string + token: string + fetchPromise: Promise + }): Promise { + const stillCurrent = (): boolean => + this.optimisticReconcileTokens.get(args.worktreeId) === args.token + const emit = (event: Omit): void => { + if (!stillCurrent()) { + return + } + this.notifier?.worktreeBaseStatus?.({ + repoId: args.repoId, + worktreeId: args.worktreeId, + base: args.base.base, + remote: args.base.remote, + ...event + }) + } + const resolvePublishRemote = async (): Promise => { + // Why: repos whose canonical publish remote is named differently (e.g. + // `upstream`, a forked `myfork`, or any non-`origin` configuration — + // including multi-segment names like `foo/bar` that this PR's resolver + // explicitly supports) would otherwise silently skip the conflict + // signal. Resolve from git config in priority order: + // 1) branch..pushRemote (explicit per-branch override) + // 2) remote.pushDefault (workspace-wide override) + // 3) branch..remote (tracked remote) + // 4) the base ref's own remote (matches resolveRemoteTrackingBase) + // 5) `origin` as a final fallback. + const tryConfig = async (key: string): Promise => { + try { + const { stdout } = await gitExecFileAsync(['config', '--get', key], { + cwd: args.repoPath + }) + const value = stdout.trim() + return value || null + } catch { + return null + } + } + return ( + (await tryConfig(`branch.${args.branchName}.pushRemote`)) ?? + (await tryConfig('remote.pushDefault')) ?? + (await tryConfig(`branch.${args.branchName}.remote`)) ?? + args.base.remote ?? + 'origin' + ) + } + const checkPublishRemoteConflict = async (): Promise => { + const publishRemote = await resolvePublishRemote() + try { + if (publishRemote !== args.base.remote) { + const result = await this.getOrStartRemoteFetch(args.repoPath, publishRemote) + if (!result.ok) { + return + } + } + await gitExecFileAsync( + ['rev-parse', '--verify', `refs/remotes/${publishRemote}/${args.branchName}^{commit}`], + { cwd: args.repoPath } + ) + if (stillCurrent()) { + this.notifier?.worktreeRemoteBranchConflict?.({ + repoId: args.repoId, + worktreeId: args.worktreeId, + remote: publishRemote, + branchName: args.branchName + }) + } + } catch { + // No publish-remote conflict is the common case; stay quiet. + } + } + + try { + const fetchResult = await args.fetchPromise + if (!stillCurrent()) { + return + } + if (!fetchResult.ok) { + emit({ status: 'unknown' }) + return + } + + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', `${args.base.ref}^{commit}`], + { cwd: args.repoPath } + ) + const postFetchSha = stdout.trim() + if (postFetchSha === args.createdBaseSha) { + emit({ status: 'current' }) + await checkPublishRemoteConflict() + return + } + + try { + await gitExecFileAsync(['merge-base', '--is-ancestor', args.createdBaseSha, postFetchSha], { + cwd: args.repoPath + }) + } catch { + emit({ status: 'base_changed' }) + await checkPublishRemoteConflict() + return + } + + const { stdout: countStdout } = await gitExecFileAsync( + ['rev-list', '--count', `${args.createdBaseSha}..${postFetchSha}`], + { cwd: args.repoPath } + ) + const behind = Number(countStdout.trim()) + if (!Number.isFinite(behind) || behind <= 0) { + emit({ status: 'current' }) + await checkPublishRemoteConflict() + return + } + const { stdout: logStdout } = await gitExecFileAsync( + ['log', '--format=%s', '-n', '5', `${args.createdBaseSha}..${postFetchSha}`], + { cwd: args.repoPath } + ) + emit({ + status: 'drift', + behind, + recentSubjects: logStdout.split('\n').filter((line) => line.trim().length > 0) + }) + await checkPublishRemoteConflict() + } catch (err) { + console.warn(`[worktree-base-status] reconcile failed for ${args.worktreeId}:`, err) + emit({ status: 'unknown' }) + } finally { + // Why: reconcile is one-shot; clear the token so long-lived sessions + // that create many worktrees without removing them don't grow the + // optimisticReconcileTokens map monotonically. Removal still no-ops + // because the entry is already gone. + if (this.optimisticReconcileTokens.get(args.worktreeId) === args.token) { + this.optimisticReconcileTokens.delete(args.worktreeId) + } + } + } + + /** + * Probe how far the worktree's HEAD is behind its tracking remote. Returns + * null when the probe cannot establish a signal (no default base ref, or + * git failure). Dispatch treats null as "unknown — proceed" (§3.1); only + * knowing-and-stale refuses. + */ + async probeWorktreeDrift(worktreeSelector: string): Promise<{ + base: string + behind: number + recentSubjects: string[] + } | null> { + const wt = await this.resolveWorktreeSelector(worktreeSelector) + if (!this.store) { + return null + } + const repo = this.store.getRepos().find((r) => r.id === wt.repoId) + if (!repo) { + return null + } + if (repo.connectionId) { + // Why: the drift probe uses local git helpers. Until the SSH provider + // exposes equivalent remote refs/log plumbing, fail closed to "unknown" + // instead of probing a server path on the desktop filesystem. + return null + } + const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const meta = this.store.getWorktreeMeta(wt.id) + const base = + meta?.baseRef || + meta?.sparseBaseRef || + repo.worktreeBaseRef || + (await getBaseRefDefault(repo.path, localWorktreeGitOptions)) + if (!base) { + // Why: brand-new repo with no remote primary — nothing to compare + // against, so there's no meaningful drift to report. Dispatch should + // not block on a probe that cannot form an opinion. + return null + } + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + base, + localWorktreeGitOptions + ) + if (!remoteTrackingBase) { + return null + } + const remote = remoteTrackingBase.remote + // Why: fetch failures are non-fatal; we proceed with whatever the + // last-known remote ref points at. `fetchRemoteWithCache` never throws. + await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) + const drift = await getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) + if (!drift) { + return null + } + // Why: behind=0 proves HEAD..base is empty, so git log cannot add subjects. + const recentSubjects = + drift.behind > 0 + ? await getRecentDriftSubjects( + wt.path, + 'HEAD', + base, + DRIFT_PROBE_SUBJECT_LIMIT, + localGitExecOptions + ) + : [] + return { base, behind: drift.behind, recentSubjects } + } + + async updateManagedWorktreeMeta( + worktreeSelector: string, + updates: Omit, 'pushTarget'> & { + pushTarget?: GitPushTarget | null + lineage?: { + parentWorktree?: string + noParent?: boolean + } + } + ) { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const { lineage, ...metaUpdates } = updates + if (lineage?.parentWorktree) { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(worktree.repoId) + } + const shouldClearPushTarget = + Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null + const normalizedMetaUpdates: Partial = shouldClearPushTarget + ? { ...metaUpdates, pushTarget: undefined } + : (metaUpdates as Partial) + const persistedMetaUpdates: Partial = omitUndefinedProperties( + normalizedMetaUpdates.displayName !== undefined + ? { + ...normalizedMetaUpdates, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + } + : normalizedMetaUpdates + ) + if (shouldClearPushTarget) { + // Why: omitUndefinedProperties protects ordinary optional RPC fields, but + // pushTarget:null is an explicit request to remove persisted target metadata. + persistedMetaUpdates.pushTarget = undefined + } + if (lineage?.noParent === true) { + this.store.removeWorktreeLineage?.(worktree.id) + this.store.removeWorkspaceLineage?.(worktreeWorkspaceKey(worktree.id)) + } else if (lineage?.parentWorktree) { + const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) + + this.validateLineageParent(worktree, parent) + if (!worktree.instanceId || !parent.instanceId) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Worktree instance identity was unavailable.' + ) + } + if (!this.store.setWorktreeLineage) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Worktree lineage storage was unavailable.' + ) + } + const createdAt = Date.now() + this.store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt + }) + this.store.setWorkspaceLineage?.({ + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + childInstanceId: worktree.instanceId, + parentWorkspaceKey: worktreeWorkspaceKey(parent.id), + parentInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt + }) + } + const metadataUpdates = stripOrcaProvenanceMetaUpdates(persistedMetaUpdates) + const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId + if (executionHostId && this.store.setWorktreeMetaForHost) { + this.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates) + } else { + this.store.setWorktreeMeta(worktree.id, metadataUpdates) + } + // Why: unlike renderer-initiated optimistic updates, CLI callers need an + // explicit push so the editor refreshes metadata changed outside the UI. + this.invalidateResolvedWorktreeCache() + this.notifyWorktreesChanged(worktree.repoId) + return await this.showManagedWorktree( + worktree.identity?.key ? `identity:${worktree.identity.key}` : `id:${worktree.id}` + ) + } + + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const store = this.store + const updates = planWorktreeSortOrderUpdates( + orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + if (updates.length === 0) { + return { updated: 0 } + } + this.invalidateResolvedWorktreeCache() + const changedRepoIds = new Set( + updates.flatMap((update) => { + const parsed = splitWorktreeId(update.worktreeId) + return parsed ? [parsed.repoId] : [] + }) + ) + for (const repoId of changedRepoIds) { + this.notifyWorktreesChanged(repoId) + } + return { updated: updates.length } + } + + async resolveManagedPrBase(args: { + repoSelector: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + }): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + let repo: Repo + try { + repo = await this.resolveRepoSelector(args.repoSelector) + } catch { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localGitExecOptions = sshGitProvider + ? undefined + : getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = sshGitProvider + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const gitExec = sshGitProvider + ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. + const resolveRemote = (): Promise => + resolveGitHubReviewHeadRemote({ + repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: localWorktreeGitOptions, + gitExec + }) + + // Why: SSH review-head fetches require narrow write-capable RPCs. + const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => + fetchPrHeadTrackingRef( + repo, + sshGitProvider, + remote, + branch, + localGitExecOptions ? { localGitExecOptions } : {} + ) + const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => + fetchGitHubPullRequestHeadRef( + repo, + sshGitProvider, + remote, + prNumber, + localGitExecOptions ? { localGitExecOptions } : {} + ) + + return resolveGitHubPrStartPoint({ + repoPath: repo.path, + prNumber: args.prNumber, + headRefName: args.headRefName, + baseRefName: args.baseRefName, + isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: localWorktreeGitOptions, + gitExec, + fetchRemoteTrackingRef, + fetchPullRequestHeadRef, + resolveRemote + }) + } + + async resolveManagedMrBase(args: { + repoSelector: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean + }): Promise< + { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string } + > { + if (!this.store) { + throw new Error('runtime_unavailable') + } + let repo: Repo + try { + repo = await this.resolveRepoSelector(args.repoSelector) + } catch { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localGitExecOptions = sshGitProvider + ? undefined + : getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = sshGitProvider + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const gitExec = sshGitProvider + ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) + + let sourceBranch = args.sourceBranch?.trim() ?? '' + let targetBranch = args.targetBranch?.trim() ?? '' + let isCrossRepository = args.isCrossRepository === true + + if (!sourceBranch) { + let remote: string + try { + remote = await this.resolveGitLabIssueSourceRemote( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + localWorktreeGitOptions + ) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) + const projectRef = await getGitLabProjectRefForRemote( + repo.path, + remote, + knownHosts, + repo.connectionId ?? null, + localWorktreeGitOptions + ) + if (!projectRef) { + return { error: 'No GitLab project found for this repository.' } + } + const item = await getGitLabWorkItemByProjectRef( + repo.path, + projectRef, + args.mrIid, + 'mr', + repo.connectionId ?? null, + localWorktreeGitOptions + ) + if (!item || item.type !== 'mr') { + return { error: `MR !${args.mrIid} not found.` } + } + sourceBranch = (item.branchName ?? '').trim() + targetBranch = (item.baseRefName ?? '').trim() + if (!sourceBranch) { + return { error: `MR !${args.mrIid} has no source branch.` } + } + if (item.isCrossRepository === true) { + isCrossRepository = true + } + } + + let remote: string + try { + remote = await this.resolveGitLabIssueSourceRemote( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + localWorktreeGitOptions + ) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined + const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise => { + await (sshGitProvider + ? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) + : gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])) + } + // Why: the target/compare branch is optional (it only powers the diff + // base). A merged MR may have had its target ref deleted, so a fetch + // failure must NOT abort the whole resolution — that would discard the + // already-verified source-branch base and silently fall back to the repo + // default branch. Degrade gracefully by dropping compareBaseRef instead. + const fetchCompareBaseRef = (): Promise => + fetchCompareBaseRefWithLocalFallback({ + compareBaseRef, + fetchCompareBaseRef: (ref) => fetchRemoteTrackingRef(targetBranch, ref), + gitExec, + logLabel: '[runtime:resolveManagedMrBase]', + logContext: { remote, targetBranch, mrIid: args.mrIid } + }) + + if (isCrossRepository) { + const mrRef = `refs/merge-requests/${args.mrIid}/head` + // Why: soft-keep needs identity when the fetch throws before returning a path. + // Success uses the path returned by the fetch itself (writer-authoritative). + let softKeepLocalRefPromise: Promise | undefined + const resolveSoftKeepLocalRef = (): Promise => { + softKeepLocalRefPromise ??= (async () => { + try { + const { stdout } = await gitExec(['remote', 'get-url', remote]) + const remoteUrl = stdout.trim() + if (!remoteUrl) { + return null + } + return gitlabMergeRequestHeadLocalRef( + reviewHeadRemoteRefComponent(remote, remoteUrl), + args.mrIid + ) + } catch { + return null + } + })() + return softKeepLocalRefPromise + } + const resolveDurableHeadSha = async (localRef: string | null): Promise => { + if (!localRef) { + return null + } + try { + const { stdout } = await gitExec(['rev-parse', '--verify', `${localRef}^{commit}`]) + return stdout.trim() || null + } catch { + return null + } + } + try { + const localRef = await fetchGitLabMergeRequestHeadRef( + repo, + sshGitProvider, + remote, + args.mrIid, + localGitExecOptions ? { localGitExecOptions } : {} + ) + const sha = await resolveDurableHeadSha(localRef) + if (!sha) { + return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } + } + const compareBaseFetched = await fetchCompareBaseRef() + return { baseBranch: sha, ...(compareBaseFetched ? { compareBaseRef } : {}) } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Why: mirror compare-base — a transient transport failure must not fail + // the resolve when a prior fetch already pinned the durable head ref. A + // missing remote ref (deleted MR/fork), auth failure, or stale-relay + // error must fail hard: serving the durable ref there would check out a + // dead or unauthorized tip and mask the actionable error. + if (isTransientReviewHeadFetchError(error)) { + const localSha = await resolveDurableHeadSha(await resolveSoftKeepLocalRef()) + if (localSha) { + console.warn( + '[runtime:resolveManagedMrBase] MR head fetch failed; using durable local ref', + { + remote, + mrIid: args.mrIid, + error: message.split('\n')[0] + } + ) + const compareBaseFetched = await fetchCompareBaseRef() + return { baseBranch: localSha, ...(compareBaseFetched ? { compareBaseRef } : {}) } + } + } + return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } + } + } + + try { + await fetchRemoteTrackingRef(sourceBranch, `refs/remotes/${remote}/${sourceBranch}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } + } + + const remoteRef = `${remote}/${sourceBranch}` + try { + await gitExec(['rev-parse', '--verify', remoteRef]) + } catch { + return { error: `Remote ref ${remoteRef} does not exist after fetch.` } + } + const compareBaseFetched = await fetchCompareBaseRef() + return { + baseBranch: remoteRef, + ...(compareBaseFetched ? { compareBaseRef } : {}), + pushTarget: { remoteName: remote, branchName: sourceBranch } + } + } + + private async resolveGitLabIssueSourceRemote( + repoPath: string, + preference?: Repo['issueSourcePreference'], + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ): Promise { + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const localGitOptionArgs = + Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] + if (preference === 'origin') { + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + ...localGitOptionArgs + ) + if (origin) { + return 'origin' + } + throw new Error('No GitLab project found for origin.') + } + if (preference === 'upstream') { + const upstream = await getGitLabProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId, + ...localGitOptionArgs + ) + if (upstream) { + return 'upstream' + } + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + ...localGitOptionArgs + ) + if (origin) { + return 'origin' + } + throw new Error('No GitLab project found for upstream or origin.') + } + const upstream = await getGitLabProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId, + ...localGitOptionArgs + ) + if (upstream) { + return 'upstream' + } + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + ...localGitOptionArgs + ) + if (origin) { + return 'origin' + } + if (connectionId) { + const provider = requireSshGitProvider(connectionId) + const { stdout } = await provider.exec(['remote'], repoPath) + return pickPreferredGitRemote(stdout.split('\n')) + } + return getDefaultRemote(repoPath, localGitOptions) + } + + private async resolveWorktreeRemovalTarget( + worktreeSelector: string, + requiredHostId?: ExecutionHostId + ): Promise { + try { + const exactTarget = parseExactWorktreeIdSelector(worktreeSelector) + const worktree = + exactTarget && requiredHostId + ? ((await this.resolveExplicitWorktreeIdScoped(exactTarget.id, requiredHostId)) ?? + (() => { + throw new Error('selector_not_found') + })()) + : await this.resolveWorktreeSelector(worktreeSelector) + const removalTarget = { + id: worktree.id, + repoId: worktree.repoId, + path: worktree.path + } + return worktree.pushTarget + ? { ...removalTarget, pushTarget: worktree.pushTarget } + : removalTarget + } catch (error) { + if (!(error instanceof Error) || error.message !== 'selector_not_found') { + throw error + } + const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) + const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined + if ( + !removalTarget || + !meta || + (requiredHostId !== undefined && meta.hostId !== requiredHostId) + ) { + throw error + } + // Why: delete requests can arrive after Git no longer lists the worktree. + // Only exact IDs with persisted Orca metadata are accepted here so + // branch/path selectors cannot resolve to an arbitrary missing path. + return meta.pushTarget ? { ...removalTarget, pushTarget: meta.pushTarget } : removalTarget + } + } + + private removeWorktreeMetadataAndHistory( + store: RuntimeStore, + worktreeId: string, + hostId?: ExecutionHostId + ): void { + // Why: worktree IDs are path-derived and can be recreated, so removal must + // purge history and process-local caches before the ID points at new state. + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = splitWorktreeId(worktreeId)?.repoId + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + (repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))) + ) + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } + if (!preservesSameIdOwner) { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + advertisedUrlWatcher.forgetWorktree(worktreeId) + deleteWorktreeHistoryDir(worktreeId) + this.closeHeadlessBrowserPagesForWorktree(worktreeId) + closeClientHostedBrowserPagesForWorktree(this, worktreeId) + } + } + + // Why: headless offscreen browser pages are main-process BrowserWindows that + // outlive a worktree unless explicitly closed — removing a worktree without + // closing its open panes leaks the windows for the life of the serve process. + private closeHeadlessBrowserPagesForWorktree(worktreeId: string): void { + if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { + return + } + for (const tab of this.agentBrowserBridge.tabList(worktreeId).tabs) { + void this.offscreenBrowserBackend.closeTab(tab.browserPageId).catch(() => {}) + } + } + + private rememberPreservedBranchCleanupTarget( + worktreeId: string, + hostId: ExecutionHostId | undefined, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined + ): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + this.preservedBranchCleanupByScope.set( + preservedBranchCleanupScopeKey({ worktreeId, hostId }), + { + worktreeId, + ...(hostId ? { hostId } : {}), + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + } + ) + return + } + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId, hostId }) + ) + } + + private preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined + ): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } + } + + async forceDeletePreservedBranch( + worktreeSelector: string, + branchName: string, + expectedHead: string, + hostId?: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) + const normalizedHostId = parseExecutionHostId(hostId)?.id + const exactTarget = removalTarget + ? this.preservedBranchCleanupByScope.get( + preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) + ) + : undefined + const legacyMatches = + removalTarget && !hostId + ? [...this.preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === removalTarget.id && + target.branchName === branchName && + target.head === expectedHead + ) + : [] + const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) + if ( + !removalTarget || + !cleanupTarget || + cleanupTarget.branchName !== branchName || + cleanupTarget.head !== expectedHead + ) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + + const repoOwner = resolveWorktreeRemovalRepoOwner( + this.store, + removalTarget.repoId, + cleanupTarget.hostId + ) + if (repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined + if (!repo) { + throw new Error('repo_not_found') + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + // Why: SSH must use the write-capable relay RPC; the shared exec-based + // helper routes through the read-only git.exec allowlist, which rejects + // the worktree/update-ref/config writes this delete needs. + await provider.forceDeletePreservedBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + removalTarget.id, + cleanupTarget.pushTarget, + this.store + ) + } else { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + await (Object.keys(localWorktreeGitOptions).length > 0 + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + cleanupTarget.pushTarget, + this.store, + localWorktreeGitOptions + ) + } + + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupTarget.hostId + }) + ) + return { deleted: true } + } + + async removeManagedWorktree( + worktreeSelector: string, + force = false, + runHooks = false, + // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` + // alone is already set by the ordinary delete confirmation. + allowUnverifiedPtyStop = false, + hostId?: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const store = this.store + const cleanupHostId = parseExecutionHostId(hostId)?.id + const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) + const cleanupScopeKey = preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupHostId + }) + const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) + const inFlightRemoval = this.removeManagedWorktreeInFlight.get(cleanupScopeKey) + if (inFlightRemoval) { + if (inFlightRemoval.optionsKey === optionsKey) { + return inFlightRemoval.promise + } + throw new Error(`Worktree deletion already in progress: ${removalTarget.id}`) + } + + // Why: runtime callers can race the same workspace through CLI/mobile + // retries. Share one destructive operation per host-qualified workspace. + const removal = (async (): Promise => { + // Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without + // this span their freezes are as invisible as desktop deletes were before `worktree.remove`. + return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { + // Why (STA-4343): a repo id can exist once per host. Honor the caller's + // host qualifier, and refuse an unqualified delete the runtime cannot + // pin to one owner rather than deleting a same-id workspace elsewhere. + const repoOwner = resolveWorktreeRemovalRepoOwner( + store, + removalTarget.repoId, + cleanupHostId + ) + if (repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined + // Why (STA-4343): metadata is keyed by the bare id, so purging it unqualified + // would evict a same-id row owned by another host. A caller that named no host + // still resolved exactly one repo above, and that repo names the owner. + const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId + if (!repo) { + const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) + if (cleanupHostId && orphanHost?.id !== cleanupHostId) { + throw new Error( + `Workspace identity for ${removalTarget.id} no longer belongs to ${cleanupHostId}. Refresh projects and try again.` + ) + } + const sshPtyProvider = + orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined + const ptyProvider = sshPtyProvider ?? this.getLocalProvider() + const externalOrphanHost = orphanHost?.kind === 'ssh' || orphanHost?.kind === 'runtime' + if (ptyProvider) { + // External host inventories must never sweep a same-id local workspace. + await killAllProcessesForWorktree(removalTarget.id, { + runtime: this, + resolvedWorktreeId: removalTarget.id, + ...(orphanHost?.kind === 'ssh' ? { resolvedConnectionId: orphanHost.targetId } : {}), + ...(orphanHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: orphanHost.environmentId } + : {}), + localProvider: ptyProvider, + onPtyStopped: this.onPtyStopped ?? undefined, + ...(externalOrphanHost + ? { + includeProviderInventory: orphanHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((error) => { + console.warn( + `[worktree-teardown] orphan cleanup failed for ${removalTarget.id}:`, + error + ) + }) + } + // Why: nothing is deleted on disk here, so watchers must be restored — a folder + // workspace or explorer pane rooted at the same path stays live. + const orphanFullPath = splitWorktreeId(removalTarget.id)?.worktreePath + const orphanWatcherPath = + splitWorktreeIdForFilesystem(removalTarget.id)?.worktreePath === orphanFullPath + ? orphanFullPath + : undefined + if (orphanWatcherPath) { + await this.acquireFileWatcherRemoval( + orphanWatcherPath, + orphanHost?.kind === 'ssh' ? orphanHost.targetId : undefined + ) + .then((gate) => gate.finish(false)) + .catch(() => {}) + } + await deleteRemoteWorktreeHistory(sshPtyProvider, removalTarget.id) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory( + store, + removalTarget.id, + cleanupHostId ?? orphanHost?.id + ) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(removalTarget.repoId) + // Why: non-desktop callers must be able to tell "forgotten" from "deleted"; nothing left the disk. + return { + warning: `Project ${removalTarget.repoId} is no longer tracked, so ${removalTarget.path} was forgotten without deleting the directory or its Git worktree registration.` + } + } + if (isFolderRepo(repo)) { + if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + // This service runs inside the selected runtime, so runtime-stamped repos use its + // local PTY namespace; only a direct SSH connection is external from here. + const folderConnectionId = repo.connectionId?.trim() || null + const folderSshPtyProvider = folderConnectionId + ? this.getSshProviderFn?.(folderConnectionId) + : undefined + const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() + if (folderPtyProvider) { + // Why: folder workspace deletion has no Git removal phase where PTYs + // would otherwise be swept; tear them down before hiding the workspace. + await killAllProcessesForWorktree(removalTarget.id, { + runtime: this, + resolvedWorktreeId: removalTarget.id, + ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), + localProvider: folderPtyProvider, + onPtyStopped: this.onPtyStopped ?? undefined, + ...(folderConnectionId + ? { + includeProviderInventory: Boolean(folderSshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) + }) + } + await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) + const removedMeta = resolveWorktreeRemovalMetadata( + store, + removalTarget.repoId, + removalTarget.id, + cleanupHostId ?? getRepoExecutionHostId(repo) + ) + const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + removalTarget.path, + registeredWorktrees + ) + if (!registeredWorktree) { + let canCleanOrphanedDirectory = false + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + removalTarget.path, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + const removalGate = await this.acquireFileWatcherRemoval( + removalTarget.path, + repo.connectionId + ) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { + connectionId: repo.connectionId, + allowUnverifiedStop: allowUnverifiedPtyStop + }) + await fsProvider!.deletePath(removalTarget.path, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + await deleteRemoteWorktreeHistory( + this.getSshProviderFn?.(repo.connectionId), + removalTarget.id + ) + } else { + const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { + allowUnverifiedStop: allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + } + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath( + removalTarget.path, + localWorktreeGitOptions + ) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath: removalTarget.path, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => + isLocalRuntimeGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { + allowUnverifiedStop: allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + } + if ( + await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions) + ) { + if (!force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery + // path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone from Git and disk. + // Finish runtime metadata cleanup without requiring force or touching + // any unregistered path that still exists. + await (repo.connectionId + ? cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + : cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + )) + if (repo.connectionId) { + await deleteRemoteWorktreeHistory( + this.getSshProviderFn?.(repo.connectionId), + removalTarget.id + ) + } + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`) + } + const canonicalWorktreePath = registeredWorktree.path + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + // Why: a Git lock must block before archive hooks or linked-path cleanup + // mutate the workspace; dirty-file force is a separate permission. + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + + // Why: a prior forced Windows recovery can delete the directory but leave + // Git's stale registration; recover and verify it before clearing metadata. + if ( + !repo.connectionId && + force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + cleanupHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return removalResult ?? {} + } + if (repo.connectionId) { + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const removalGate = await this.acquireFileWatcherRemoval( + canonicalWorktreePath, + repo.connectionId + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { + connectionId: repo.connectionId, + allowUnverifiedStop: allowUnverifiedPtyStop + }) + rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, force)) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + const removalResult = this.preserveBranchHeadFallback( + rawRemovalResult, + registeredWorktree.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + removalTarget.id, + removedPushTarget, + store + ) + await deleteRemoteWorktreeHistory( + this.getSshProviderFn?.(repo.connectionId), + removalTarget.id + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + cleanupHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return removalResult ?? {} + } + + const hooks = getEffectiveHooks(repo) + let warning: string | undefined + if (hooks?.scripts.archive && runHooks) { + const result = await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined + ) + if (!result.success) { + console.error( + `[hooks] archive hook failed for ${canonicalWorktreePath}:`, + result.output + ) + } + } else if (hooks?.scripts.archive) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + warning = `orca.yaml archive hook skipped for ${canonicalWorktreePath}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) + } + + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { + throw new Error( + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` + ) + } + try { + // Why: an archive hook can race another Git client that locks the row; + // recheck before linked-path, watcher, or terminal teardown side effects. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) + const ignoredLinkedPaths = force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 + ? { ignoredUntrackedPaths: ignoredLinkedPaths } + : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + // Why: Git can still classify this as an orphan after preflight; + // retain strict PTY teardown before any recursive fallback deletion. + } + + let removalResult: RemoveWorktreeResult | undefined + const removalGate = await this.acquireFileWatcherRemoval(canonicalWorktreePath) + let removalCompleted = false + try { + // Why: linked-path deletion is destructive too; PTYs must release every + // handle before Windows or WSL filesystem cleanup starts. + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { + allowUnverifiedStop: allowUnverifiedPtyStop + }) + + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) + } + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: removal already validated the Git row under the selected + // project runtime; keep branch cleanup on that same canonical row. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...localWorktreeGitOptions + } + removalResult = this.preserveBranchHeadFallback( + await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its + // recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => this.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await this.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + removalCompleted = true + return { + ...(warning ? { warning } : {}) + } + } else { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } + } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + cleanupHostId, + removalResult, + refreshedRegisteredWorktree.head, + removedPushTarget + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + return { + ...removalResult, + ...(warning ? { warning } : {}) + } + }) + })() + this.removeManagedWorktreeInFlight.set(cleanupScopeKey, { optionsKey, promise: removal }) + try { + const result = await removal + this.emitWorktreeLifecycle({ + kind: 'removed', + worktreeId: removalTarget.id, + path: removalTarget.path + }) + return result + } finally { + if (this.removeManagedWorktreeInFlight.get(cleanupScopeKey)?.promise === removal) { + this.removeManagedWorktreeInFlight.delete(cleanupScopeKey) + } + } + } + + async renameTerminal(handle: string, title: string | null): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + pty.pty.title = title + // Why: a manual rename must outrank later agent OSC title updates (which + // win by timestamp), so stamp it as the freshest title. + pty.pty.titleUpdatedAt = Date.now() + this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId) + // Why: without a renderer the rename only lived on the live pty and was + // lost on restart. Persist customTitle so a headless rebuild keeps it. + if (!this.notifier?.renameTerminal && pty.pty.tabId) { + this.persistHeadlessTerminalTitle(pty.pty.worktreeId, pty.pty.tabId, title) + } + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === pty.pty.ptyId) { + this.notifier?.renameTerminal(leaf.tabId, title) + return { handle, tabId: leaf.tabId, title } + } + } + return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + this.notifier?.renameTerminal(leaf.tabId, title) + return { handle, tabId: leaf.tabId, title } + } + + private async resolveAgentTerminalCreateOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions + ): Promise { + // Why: raw shell commands like `codex exec` must remain user-authored shell. + // Only unmanaged, repo-backed, bare agent launches get Settings defaults. + const callerSuppliedLaunch = + opts.env || + opts.launchConfig || + opts.launchAgent || + opts.startupCommandDelivery || + opts.claudeAgentTeamsSourceCommand + const store = this.store + if (opts.startupAgent) { + // Why: falling through unresolved would spawn a bare shell that can only time + // out waiting for an agent. A caller-supplied launch contradicts the agent: + // `command` would be overwritten, `resumeProviderSession` would pair resume + // identity with a fresh launch. + if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) { + throw new Error( + `startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.` + ) + } + if (!store) { + throw new Error('runtime_unavailable') + } + } else if (callerSuppliedLaunch || !store || !opts.command || !workspace.repo) { + return opts + } + + const settings = store.getSettings() + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { + throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) + } + const agent = + opts.startupAgent ?? + resolveBareAgentLaunchCommand({ + command: opts.command, + settings, + platform, + isRemote + }) + if (!agent) { + return opts + } + + const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences) + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), + platform, + shell: queuedShell, + isRemote, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + // Why: an explicit agent that yields no plan would otherwise spawn a bare + // shell that never reaches agent readiness. + if (opts.startupAgent) { + throw new Error(`Could not build launch command for ${opts.startupAgent}.`) + } + return opts + } + + await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) + + return { + ...opts, + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: agent, + startupCommandDelivery: startupPlan.startupCommandDelivery + } + } + + private getAgentSessionExecutionNamespace( + workspace: TerminalWorkspaceLaunchScope, + agent: TuiAgent + ): { machine: string; principal: string; container: string; providerRoot: string } | null { + if (workspace.connectionId) { + // Why: SSH target ids are not execution-namespace proof. Preserve the + // legacy launch until an attested route can safely participate in claims. + return null + } + const wsl = parseWslUncPath(workspace.path) + const principal = + typeof process.getuid === 'function' + ? `uid:${process.getuid()}` + : `user:${process.env.USERNAME ?? ''}` + return { + machine: wsl ? 'wsl-host' : `native:${process.platform}`, + principal, + container: wsl ? `wsl:${wsl.distro.toLocaleLowerCase('en-US')}` : 'native', + // Why: merging account roots is conservative (it may conflict) and can + // never permit two TUIs to own one provider session. + providerRoot: `profile-default:${agent}` + } + } + + private async executionOwnerSupportsAgentSessionOperation( + workspace: TerminalWorkspaceLaunchScope, + operation: 'resume' | 'create', + signal?: AbortSignal + ): Promise { + const provider = workspace.connectionId + ? this.getSshProviderFn?.(workspace.connectionId) + : this.getLocalProvider() + if (!provider) { + // An unavailable route is not proof of an old owner; preserve the structured failure. + return true + } + const probe = + operation === 'resume' + ? provider.supportsAgentSessionClaims + : provider.supportsAgentSessionCreateOperations + if (!probe) { + // Local in-process PTYs need no wire negotiation; unknown SSH providers are legacy. + return workspace.connectionId === null + } + try { + return (await probe.call(provider, { signal })) === true + } catch { + // Why: this read-only check has not launched anything, so the old route remains safe. + return false + } + } + + private toAgentSessionOptions( + preferences: AgentLaunchPreferences | undefined + ): Record | undefined { + if (!preferences) { + return undefined + } + const options = { + ...(preferences.model ? { model: preferences.model } : {}), + ...(preferences.effort ? { effort: preferences.effort } : {}), + ...(preferences.mode ? { mode: preferences.mode } : {}) + } + return Object.keys(options).length > 0 ? options : undefined + } + + async ensureAgentSession( + request: RuntimeEnsureAgentSessionRequest, + _caller: RuntimeAgentSessionRpcCaller = {}, + handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } + ): Promise { + if (request.kind === 'automatic') { + // Legacy renderer sleep records are migration evidence, not host authority. + throw new Error('agent_session_resume_not_authorized') + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) + const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const namespace = + resolvedNamespace && handoffAuthority + ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerRoot } + : resolvedNamespace + if ( + !namespace || + !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) + ) { + // Why: the renderer still holds the exact old request and may retry it before any side effect. + throw new Error('agent_session_legacy_required') + } + // Why: nested SSH paths belong to the execution owner, so compatibility selection must happen before local filesystem canonicalization. + const identity = canonicalizeAgentSessionIdentity(request.agent, request.providerSession) + const claim = this.agentSessionClaimSigner.createClaim({ + namespace, + identity, + canonicalWorktreeId: workspace.id + }) + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.') + } + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startup = buildAgentResumeStartupPlan({ + agent: request.agent, + providerSession: identity.providerSession, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: { + ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + ...(handoffAuthority && request.agent === 'codex' + ? { CODEX_HOME: handoffAuthority.providerRoot } + : handoffAuthority && request.agent === 'claude' + ? { CLAUDE_CONFIG_DIR: handoffAuthority.providerRoot } + : {}) + }, + ompResumeFilePath: request.ompResumeFilePath, + sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), + platform, + shell, + isRemote + }) + if (!startup) { + throw new Error('agent_session_identity_required') + } + await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) + if (_caller.signal?.aborted) { + throw new Error('client_disconnected') + } + const terminal = await this.createTerminal(`id:${workspace.id}`, { + command: startup.launchCommand, + env: startup.env, + launchConfig: startup.launchConfig, + launchAgent: request.agent, + startupCommandDelivery: startup.startupCommandDelivery, + presentation: request.presentation ?? 'background', + tabId: request.placement?.tabId, + leafId: request.placement?.leafId, + agentSessionClaim: claim, + ...(handoffAuthority + ? { + launchToken: handoffAuthority.spawnToken, + structuredAgentSessionId: handoffAuthority.sessionId + } + : {}), + signal: _caller.signal + }) + return { + terminal, + disposition: terminal.agentSessionDisposition ?? 'created' + } + } + + async createAgentSession( + request: RuntimeCreateAgentSessionRequest, + caller: RuntimeAgentSessionRpcCaller = {} + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const now = Date.now() + const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId) + if ( + operationTimestamp === null || + operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) { + throw new Error('agent_session_operation_invalid') + } + const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}` + const operationKey = `${callerKey}\0${request.clientOperationId}` + const requestFingerprint = createHash('sha256') + .update( + JSON.stringify([ + request.worktree, + request.agent, + request.prompt ?? null, + request.promptDelivery ?? null, + request.agentArgs ?? null, + request.agentArgs === undefined ? 'host-default' : 'client-override', + request.launchPreferences?.model ?? null, + request.launchPreferences?.effort ?? null, + request.launchPreferences?.mode ?? null, + request.startupCwd ?? null, + request.presentation ?? null, + request.placement?.tabId ?? null, + request.placement?.leafId ?? null, + request.viewMode ?? null + ]) + ) + .digest('base64url') + const existing = this.agentSessionCreateOperations.get(operationKey) + if (existing) { + if (existing.fingerprint !== requestFingerprint) { + throw new Error('agent_session_operation_conflict') + } + const replayed = await existing.promise + return { ...replayed, disposition: 'replayed' } + } + if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + // Why: once a tombstone could have expired, an unseen replay must never + // be reinterpreted as permission to start another fresh agent. + throw new Error('agent_session_operation_expired') + } + let callerOperationCount = 0 + const callerPrefix = `${callerKey}\0` + for (const key of this.agentSessionCreateOperations.keys()) { + if (key.startsWith(callerPrefix)) { + callerOperationCount += 1 + } + } + if ( + callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT || + this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT + ) { + // Why: tombstones cannot be evicted early without making an old replay + // capable of spawning again; reject new IDs until retained entries age out. + throw new Error('agent_session_operation_capacity') + } + let retainReplayFence = false + const operation = (async (): Promise => { + // Why: reserve the client operation before any async preflight so concurrent retries cannot + // both observe an empty ledger and reach the execution owner independently. + const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) + if ( + !(await this.executionOwnerSupportsAgentSessionOperation( + workspace, + 'create', + caller.signal + )) + ) { + // Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds. + throw new Error('agent_session_legacy_required') + } + const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd) + // Why: aliases and object property order are client syntax, not authority; + // fingerprint the host-resolved fields in one fixed order. + const resolvedFingerprint = createHash('sha256') + .update( + JSON.stringify([ + workspace.id, + request.agent, + request.prompt ?? null, + request.promptDelivery ?? null, + request.agentArgs ?? null, + request.agentArgs === undefined ? 'host-default' : 'client-override', + request.launchPreferences?.model ?? null, + request.launchPreferences?.effort ?? null, + request.launchPreferences?.mode ?? null, + startupCwd ?? null, + request.presentation ?? null, + request.placement?.tabId ?? null, + request.placement?.leafId ?? null, + request.viewMode ?? null + ]) + ) + .digest('base64url') + const settings = this.store!.getSettings() + if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = workspace.repo + ? repoIsRemote(workspace.repo) + : Boolean(workspace.connectionId) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startupArgs = { + agent: request.agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + platform, + shell, + isRemote + } + const startup = + request.promptDelivery === 'draft' + ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) + : buildAgentStartupPlan({ + ...startupArgs, + prompt: request.prompt ?? '', + allowEmptyPromptLaunch: true + }) + if (!startup) { + throw new Error('agent_session_identity_required') + } + await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) + if (caller.signal?.aborted) { + throw new Error('client_disconnected') + } + let terminal: RuntimeTerminalCreate + const executionOperationId = createHash('sha256') + .update(this.runtimeId) + .update('\0') + .update(operationKey) + .update('\0') + .update(resolvedFingerprint) + .digest('base64url') + const operationTabId = + request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`) + const operationLeafId = + request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) + const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` + try { + terminal = await this.createTerminal(`id:${workspace.id}`, { + command: startup.launchCommand, + env: startup.env, + launchConfig: startup.launchConfig, + launchAgent: request.agent, + startupCommandDelivery: startup.startupCommandDelivery, + cwd: startupCwd, + presentation: request.presentation ?? 'background', + tabId: operationTabId, + leafId: operationLeafId, + preAllocatedHandle: operationHandle, + viewMode: request.viewMode, + agentSessionCreateOperationId: executionOperationId, + signal: caller.signal, + onPtySpawnCommitted: () => { + retainReplayFence = true + } + }) + } catch (error) { + if (isAgentSessionOperationOutcomeUnknown(error)) { + retainReplayFence = true + } + throw error + } + return { terminal, disposition: 'created' } + })() + this.agentSessionCreateOperations.set(operationKey, { + fingerprint: requestFingerprint, + promise: operation + }) + const expireOperation = (): void => { + const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + const timer = setTimeout( + () => { + if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { + this.agentSessionCreateOperations.delete(operationKey) + } + }, + Math.max(1, expiresAt - Date.now()) + ) + timer.unref?.() + } + try { + const result = await operation + expireOperation() + return result + } catch (error) { + if (retainReplayFence) { + // Why: the first PTY may still be alive; replay the same failure until + // expiry instead of interpreting a lost outcome as a fresh spawn grant. + expireOperation() + } else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { + this.agentSessionCreateOperations.delete(operationKey) + } + throw error + } + } + + async createTerminal( + worktreeSelector?: string, + opts: TerminalCreateOptions = {} + ): Promise { + if (opts.startupAgent && worktreeSelector === undefined) { + // Why: the launch is resolved against a workspace, so with no selector + // startupAgent is silently dropped and the terminal is a bare shell. + throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) + } + const presentation = resolveTerminalPresentation(opts) + const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() + // Why: pre-diff createTerminal fell back to the renderer's active worktree + // when no selector was provided. The new background-spawn branch hard- + // requires a resolvable selector, so route the no-selector case through + // the renderer IPC path to preserve that behavior. + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null + const shouldCreateInBackground = + worktreeSelector !== undefined && + (Boolean(opts.agentSessionClaim) || + (!requiresRendererFocus && opts.rendererBacked !== true) || + // Why: `orca serve` exposes the local runtime without a renderer + // window. Renderer-backed and focus-requested creates are preferred on + // the renderer, but with no window a background spawn is the only + // usable path — otherwise getAuthoritativeWindow() below throws and the + // caller gets no terminal at all (#10333). Focus is not lost: the + // spawned pane is still published and revealed with `activate`. + availableAuthoritativeWindow === null) + + if (shouldCreateInBackground) { + if (!this.ptyController?.spawn) { + throw new Error('runtime_unavailable') + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) + let ptySpawnCommitReported = false + const reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + launchOpts.onPtySpawnCommitted?.() + } + const cwd = + this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path + let preAllocatedHandle = + launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() + // Why: mint tabId in main before spawn so paneKey is known at PTY env + // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys + // off `${tabId}:${leafId}` — without these vars set on the PTY, the + // hook payload arrives with an empty paneKey and the renderer cannot + // attribute the event. Use a stable UUID leaf because hooks reject the + // legacy numeric pane keys after the pane-id migration. + const hintedTabId = launchOpts.tabId?.trim() + const canAdoptPaneIdentity = + hintedTabId !== undefined && + isValidHostTerminalTabId(hintedTabId) && + launchOpts.leafId !== undefined && + isTerminalLeafId(launchOpts.leafId) + let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() + let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() + let paneKey = makePaneKey(tabId, leafId) + const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ + worktreeId: workspace.id, + connectionId: workspace.connectionId, + tabId, + leafId + }) + let stablePaneCreateReleased = false + const releaseStablePaneCreate = (): void => { + if (stablePaneCreateReleased) { + return + } + stablePaneCreateReleased = true + claimedStablePaneCreate?.() + } + try { + if (launchOpts.signal?.aborted) { + throw new Error('client_disconnected') + } + const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({ + cols: 120, + rows: 40, + cwd, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId, + leafId + }) + const launchToken = launchOpts.launchConfig + ? (launchOpts.launchToken ?? randomUUID()) + : undefined + const baseEnv = { + ...launchOpts.env, + ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) + } + const claudeAgentTeamsSourceCommand = + launchOpts.claudeAgentTeamsSourceCommand?.trim() || + launchOpts.command?.trim() || + undefined + const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode + const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( + launchOpts.launchConfig, + claudeAgentTeamsSourceCommand, + claudeAgentTeamsMode + ) + let agentTeamsPlan: Awaited> | undefined + try { + agentTeamsPlan = adoptedBeforeLaunch + ? undefined + : await buildClaudeAgentTeamsLaunchPlan({ + command: claudeAgentTeamsSourceCommand, + mode: effectiveClaudeAgentTeamsMode, + baseEnv: { + ...process.env, + ...baseEnv + }, + createTeamEnv: (shimDir, shimBin) => + this.claudeAgentTeams.createLaunchEnv({ + leaderHandle: preAllocatedHandle, + baseEnv: { + ...process.env, + ...baseEnv + }, + shimDir, + shimBin + }).env + }) + } catch (error) { + releaseStablePaneCreate?.() + throw error + } + const sequencedStartupCommand = + agentTeamsPlan && + claudeAgentTeamsSourceCommand && + launchOpts.command && + claudeAgentTeamsSourceCommand !== launchOpts.command + ? agentTeamsPlan.command + : undefined + const effectiveLaunchConfig = + launchOpts.launchConfig && agentTeamsPlan + ? { + ...launchOpts.launchConfig, + agentCommand: launchOpts.launchConfig.agentCommand + ? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32' + ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand) + : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand) + : agentTeamsPlan.command, + agentEnv: { + ...launchOpts.launchConfig.agentEnv, + ...agentTeamsPlan.env + } + } + : launchOpts.launchConfig + // Why: setup/agent sequencing wraps the PTY launch in a wait shell before + // Claude Agent Teams runs. Preserve the direct Claude command separately + // so the wrapper can exec the teammate-mode variant after setup completes. + const env = this.buildTerminalWorkspaceEnv( + workspace, + { + ...baseEnv, + ...(sequencedStartupCommand + ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } + : {}) + }, + paneKey, + tabId, + agentTeamsPlan?.env + ) + const terminalColorQueryReplies = + launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() + if (launchOpts.signal?.aborted) { + throw new Error('client_disconnected') + } + let result: Awaited>> + try { + result = await this.ptyController.spawn({ + cols: 120, + rows: 40, + cwd, + command: sequencedStartupCommand + ? launchOpts.command + : (agentTeamsPlan?.command ?? launchOpts.command), + launchAgent: launchOpts.launchAgent, + commandDelivery: 'provider', + startupCommandDelivery: launchOpts.startupCommandDelivery, + env, + envToDelete: mergeTerminalEnvDeletionKeys( + launchOpts.envToDelete, + agentTeamsPlan?.envToDelete + ), + resumeProviderSession: launchOpts.resumeProviderSession, + telemetry: launchOpts.telemetry, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId, + leafId, + ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), + ...(launchOpts.agentSessionClaim + ? { + agentSessionEnsure: { + claim: launchOpts.agentSessionClaim, + surface: { + worktreeId: workspace.id, + tabId, + leafId, + terminalHandle: preAllocatedHandle + } + } + } + : {}), + ...(launchOpts.agentSessionCreateOperationId + ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } + : {}), + ...(launchOpts.signal ? { signal: launchOpts.signal } : {}), + ...(launchOpts.onPtySpawnCommitted + ? { onPtySpawnCommitted: reportPtySpawnCommitted } + : {}), + ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), + ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), + ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), + // Why: a host-initiated create has no renderer session writer, so + // without its own binding graph sync cannot classify the terminal + // and prunes the tab out from under a running agent. + persistHostSessionBinding: true + }) + } finally { + releaseStablePaneCreate?.() + } + if (!result.stablePaneOwner) { + reportPtySpawnCommitted() + } + const adoptedStablePane = Boolean(result.stablePaneOwner) + if (result.agentSessionEnsure) { + const canonicalSurface = result.agentSessionEnsure.owner.surface + preAllocatedHandle = canonicalSurface.terminalHandle + tabId = canonicalSurface.tabId + leafId = canonicalSurface.leafId + paneKey = makePaneKey(tabId, leafId) + } else if (result.stablePaneOwner) { + preAllocatedHandle = result.stablePaneOwner.handle + tabId = result.stablePaneOwner.tabId + leafId = result.stablePaneOwner.leafId + paneKey = makePaneKey(tabId, leafId) + } + try { + this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) + } catch (error) { + if (error instanceof Error && error.message === 'agent_session_exited_during_start') { + this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) + } + throw error + } + this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } + this.registerPty(result.id, workspace.id, workspace.connectionId, { + tabId, + leafId, + terminalHandle: preAllocatedHandle, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + }) + if (launchOpts.structuredAgentSessionId) { + agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) + } + const pty = this.getOrCreatePtyWorktreeRecord(result.id) + if (pty) { + // Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs + // once the renderer de-persists the tab, i.e. when the user closes it. + pty.runtimeSessionOwned = true + if (!adoptedStablePane) { + if (launchOpts.title) { + const observedAt = this.nextTitleObservationSequence() + pty.title = launchOpts.title + pty.titleUpdatedAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) + } else { + pty.title = null + pty.titleUpdatedAt = null + } + pty.launchConfig = effectiveLaunchConfig + ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) + : null + pty.launchToken = launchToken ?? null + pty.launchIncarnationId = launchToken ? pty.incarnationId : null + pty.launchAgent = launchOpts.launchAgent ?? null + } + pty.tabId = tabId + pty.paneKey = paneKey + } + const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle + if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { + this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { + tabId, + leafId, + title: launchOpts.title ?? null, + activate: presentation === 'focused', + // Why: explicit background presentation may carry legacy activate + // metadata from an already-owned renderer pane; don't select it on mobile. + selectIfNoActiveTab: presentation !== 'background', + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) + }) + } + let surface: RuntimeTerminalCreate['surface'] = 'background' + let warning: string | undefined + if (presentation !== 'background' && this.notifier?.revealTerminalSession) { + try { + // Why: after the PTY is spawned, renderer tab adoption is best-effort; + // failing here must not strand a live process without returning a handle. + // Pass the pre-minted tabId so the renderer adopts under the same id + // already baked into the PTY env — keeps paneKey hook attribution intact. + await this.notifier.revealTerminalSession(workspace.id, { + ptyId: result.id, + title: launchOpts.title ?? null, + ...(cwd !== workspace.path ? { cwd } : {}), + ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), + ...(launchToken ? { launchToken } : {}), + ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + activate: presentation === 'focused', + ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false), + tabId, + leafId + }) + surface = 'visible' + } catch (err) { + console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) + warning = createTerminalRevealWarning(handle, err) + } + } else if (presentation !== 'background') { + warning = createTerminalRevealWarning(handle) + } + return { + handle, + tabId, + paneKey, + ptyId: result.id, + worktreeId: workspace.id, + title: pty?.title ?? launchOpts.title ?? null, + ...this.getPtyExecutionHostMetadata(result.id), + surface, + ...(result.pid ? { processId: result.pid } : {}), + ...(result.agentSessionEnsure + ? { agentSessionDisposition: result.agentSessionEnsure.disposition } + : {}), + ...(adoptedStablePane ? { isReattach: true as const } : {}), + ...(warning ? { warning } : {}) + } + } finally { + releaseStablePaneCreate() + } + } + + this.assertGraphReady() + const win = rendererWindow ?? this.getAuthoritativeWindow() + // Why: mirrors browserTabCreate — when no worktree is specified, pass + // undefined so the renderer uses its current active worktree. + const workspace = worktreeSelector + ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + : null + const launchOpts = workspace + ? await this.resolveAgentTerminalCreateOptions(workspace, opts) + : opts + const worktreeId = workspace?.id + const cwd = workspace + ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) + : launchOpts.cwd + const requestId = randomUUID() + + // Why: terminal creation is a renderer-side Zustand store operation (like + // browser tab creation). The main process sends a request, the renderer + // creates the tab and replies with the tabId so we can resolve the handle. + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal creation timed out')) + }, 10_000) + + const handler = ( + event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || r.requestId !== requestId) { + return + } + clearTimeout(timer) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) + } + } + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + command: launchOpts.command, + cwd, + ...(launchOpts.env ? { env: launchOpts.env } : {}), + ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), + ...(launchOpts.resumeProviderSession + ? { resumeProviderSession: launchOpts.resumeProviderSession } + : {}), + ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), + ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + startupCommandDelivery: launchOpts.startupCommandDelivery, + title: launchOpts.title, + activate: presentation === 'focused', + ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false) + }) + }) + + // Why: the renderer created the tab immediately, but the graph sync that + // populates this.leaves may not have arrived yet. Wait for the leaf to + // appear so we can return a valid handle the caller can use right away. + const handle = await this.waitForTerminalHandle(reply.tabId) + return { + handle, + tabId: reply.tabId, + worktreeId: worktreeId ?? '', + title: reply.title, + ...this.getPtyExecutionHostMetadata(this.handles.get(handle)?.ptyId ?? null), + surface: 'visible' + } + } + + async dedupeTerminalCreate( + clientIdentity: string, + worktreeSelector: string | undefined, + clientMutationId: string | undefined, + reconcileExisting: boolean, + run: ( + canonicalWorktreeSelector: string | undefined, + preAllocatedHandle: string | undefined + ) => Promise + ): Promise { + if (!clientMutationId || !worktreeSelector) { + if (reconcileExisting) { + throw new Error('runtime_unavailable') + } + return await run(worktreeSelector, undefined) + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const canonicalWorktreeSelector = `id:${workspace.id}` + const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle( + clientIdentity, + workspace.id, + clientMutationId + ) + return this.terminalCreateIdempotency.run( + clientIdentity, + workspace.id, + clientMutationId, + async () => { + if (reconcileExisting) { + const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle) + if (adopted) { + return adopted + } + } + return await run(canonicalWorktreeSelector, preAllocatedHandle) + } + ) + } + + private async reconcileRemoteTerminalCreate( + worktreeId: string, + terminalHandle: string + ): Promise { + if (!this.ptyController?.listProcesses) { + throw new Error('runtime_unavailable') + } + const listed = await withTimeoutResult( + this.ptyController.listProcesses(), + PTY_CONTROLLER_LIST_TIMEOUT_MS + ) + if (!listed.ok) { + // Why: unknown inventory cannot prove the first create failed, so spawning could duplicate a live shell. + throw new Error('runtime_unavailable') + } + const matches = listed.value.filter((session) => session.terminalHandle === terminalHandle) + if (matches.length > 1) { + throw new Error('terminal_create_identity_conflict') + } + if (matches.length === 0) { + const sameWorktreeHasUnknownIdentity = listed.value.some( + (session) => + (session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)) === worktreeId && + !session.terminalHandle + ) + if (sameWorktreeHasUnknownIdentity) { + // Why: older retained providers may list the first shell without its handle; absence is not authoritative in that shape. + throw new Error('runtime_unavailable') + } + return null + } + const session = matches[0] + const authoritativeWorktreeId = session.worktreeId ?? inferWorktreeIdFromPtyId(session.id) + if (authoritativeWorktreeId !== worktreeId) { + // Why: a reused address or forged provider record must never adopt a PTY from another workspace. + throw new Error('terminal_create_identity_conflict') + } + this.adoptControllerTerminalHandle(session.id, terminalHandle) + const pty = this.recordPtyWorktree(session.id, worktreeId, { + connected: true, + title: session.title + }) + const adoptedHandle = this.issuePtyHandle(pty) + if (adoptedHandle !== terminalHandle) { + throw new Error('terminal_create_identity_conflict') + } + return { + handle: adoptedHandle, + ptyId: session.id, + worktreeId, + title: session.title || null, + surface: 'background' + } + } + + private getPtyExecutionHostMetadata( + ptyId: string | null + ): Pick { + if (!ptyId) { + return {} + } + const pty = this.ptysById.get(ptyId) + if (!pty) { + return {} + } + if (pty.connectionId) { + const remotePlatform = getRegisteredSshState(pty.connectionId)?.remotePlatform + return { + executionHostId: toSshExecutionHostId(pty.connectionId), + ...(remotePlatform ? { hostPlatform: remotePlatform } : {}) + } + } + return { + executionHostId: LOCAL_EXECUTION_HOST_ID, + hostPlatform: pty.isWsl || pty.wslDistro ? 'linux' : process.platform + } + } + + async launchAgentTerminal( + worktreeSelector: string, + opts: { agent: TuiAgent; prompt: string; title?: string } + ): Promise { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('Repository for the selected workspace is no longer available.') + } + const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + await this.markWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) + return await this.createTerminal(`id:${worktree.id}`, { + command: startup.startup.command, + env: startup.startup.env, + ...(startup.startup.launchConfig ? { launchConfig: startup.startup.launchConfig } : {}), + launchAgent: startup.agent, + startupCommandDelivery: startup.startup.startupCommandDelivery, + telemetry: startup.startup.telemetry, + title: opts.title + }) + } + + // Why: dedupes a worktree.create whose response was lost when a mobile + // connection migration (relay/direct hand-off on shoddy cellular) rejected the + // in-flight request. A retry with the same clientMutationId returns the + // in-flight or just-finished create instead of a duplicate worktree; failures + // drop immediately so a genuine retry starts fresh, and successes linger + // briefly so a retry whose response was lost in the cutover still reconciles. + dedupeWorktreeCreate( + repoSelector: string, + clientMutationId: string | undefined, + run: () => Promise + ): Promise { + if (!clientMutationId) { + return run() + } + const key = `${repoSelector}\0${clientMutationId}` + const inflight = this.worktreeCreateByMutationId.get(key) + if (inflight) { + return inflight as Promise + } + const created = run() + this.worktreeCreateByMutationId.set(key, created) + const drop = (): void => { + if (this.worktreeCreateByMutationId.get(key) === created) { + this.worktreeCreateByMutationId.delete(key) + } + } + void created.then(() => { + setTimeout(drop, WORKTREE_CREATE_RESULT_TTL_MS).unref?.() + }, drop) + return created + } + + async createMobileSessionTerminal( + worktreeSelector: string, + opts: { + afterTabId?: string + targetGroupId?: string + command?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + agentPrompt?: string + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + activate?: boolean + select?: boolean + clientNavigationId?: string + navigation?: RuntimeNavigationTarget + clientMutationId?: string + signal?: AbortSignal + } = {} + ): Promise { + const navigation = opts.navigation ?? 'all' + const select = opts.select ?? opts.activate !== false + const runOpts = { + ...opts, + activate: select && navigationTargetsHost(navigation) + } + const mutationId = opts.clientMutationId + let result: RuntimeMobileSessionCreateTerminalResult + if (!mutationId) { + result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + } else { + // Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result. + const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}` + // Why: a retried create (double-tap, reconnect replay) with the same + // idempotency key must return the in-flight operation instead of spawning a + // duplicate terminal. Successes are kept briefly so a retry whose response + // was lost in transit reuses the created terminal; failures are dropped + // immediately so a retry can start a fresh create. + const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) + const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) + if (!inflight) { + this.mobileTerminalCreateByMutationId.set(mutationKey, run) + const drop = (): void => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + } + void run.then(() => { + setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() + }, drop) + } + result = await run + } + if (select) { + const worktreeId = + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + this.applyMobileSessionTabNavigation( + this.getMobileSessionTabsForWorktree(worktreeId), + result.tab.id, + navigation, + opts.clientNavigationId + ) + } + return result + } + + private async runCreateMobileSessionTerminal( + worktreeSelector: string, + opts: { + afterTabId?: string + targetGroupId?: string + command?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + agentPrompt?: string + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + activate?: boolean + clientNavigationId?: string + clientMutationId?: string + signal?: AbortSignal + } = {} + ): Promise { + const pairedCreate = Boolean(opts.clientNavigationId) + const graphEpoch = this.captureReadyGraphEpoch() + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const worktreeId = workspace.id + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + let afterDesktopTabId: string | undefined + if (opts.afterTabId) { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) + if (!anchor) { + throw new Error('after_tab_not_found') + } + afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id + } + const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) + this.assertStableReadyGraph(graphEpoch) + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await this.createRuntimeOwnedMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId, + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + envToDelete: startupCommand.envToDelete, + startupCommandDelivery: startupCommand.startupCommandDelivery, + launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig, + signal: opts.signal + } + ) + } + if (win.webContents.isDestroyed?.()) { + throw new Error('runtime_unavailable') + } + const releasePublicationThrottle = pairedCreate + ? this.rendererPublicationThrottle.acquire(win.webContents) + : () => {} + try { + const requestId = randomUUID() + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) + reject(new Error('Terminal creation timed out')) + }, 10_000) + // Why: a dead client connection cancels the wait; the renderer tab (and + // its shell) stays alive for the host and mirrors on reconnect (#7718). + const onAbort = (): void => { + clearTimeout(timer) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) + reject(new Error('client_disconnected')) + } + + const handler = ( + event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || r.requestId !== requestId) { + return + } + clearTimeout(timer) + getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) + opts.signal?.removeEventListener('abort', onAbort) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? '' }) + } + } + opts.signal?.addEventListener('abort', onAbort, { once: true }) + getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + afterTabId: afterDesktopTabId, + targetGroupId: opts.targetGroupId, + command: startupCommand.command, + cwd, + ...(startupCommand.env ? { env: startupCommand.env } : {}), + ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), + ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), + ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + startupCommandDelivery: startupCommand.startupCommandDelivery, + source: 'runtime-session', + activate: opts.activate + }) + }) + + if (opts.activate !== false) { + this.notifier?.focusTerminal(reply.tabId, worktreeId, null) + } + // Why: register the wait before the renderer's PTY spawn arrives so that + // spawn (registerPty) can publish the pty-backed surface main-side even if + // graph-sync is stalled (#7587). Removed in the finally below. + const pendingCreateKey = `${worktreeId}::${reply.tabId}` + // Why: a rescue publishes into the active group (opts.targetGroupId is not + // threaded); the renderer's reconciling publication then moves the tab to the + // requested group, so any wrong-group placement is cosmetic and stall-window-only. + this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { + activate: opts.activate !== false, + paired: pairedCreate, + selectIfNoActiveTab: true, + ...(startupCommand.command ? { startupCommand: startupCommand.command } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) + }) + try { + // Why: the PTY spawn and the tabCreate reply race on independent IPC + // channels; if the spawn already registered, publish immediately so the + // wait resolves without depending on a graph sync. + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) + const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { + timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, + signal: opts.signal + }) + if (this.isReadyMobileTerminalSurface(surface)) { + this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) + return surface + } + const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { + timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, + requireReady: true, + signal: opts.signal + }).catch(() => null) + if (readySurface) { + this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) + return readySurface + } + if (opts.signal?.aborted) { + // Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab. + throw new Error('client_disconnected') + } + const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) + if (!pendingSurface) { + throw new Error('Timed out waiting for terminal surface after creation') + } + // Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab. + return await this.createRuntimeOwnedMobileSessionTerminal( + worktreeId, + opts.activate !== false, + opts.afterTabId, + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + envToDelete: startupCommand.envToDelete, + startupCommandDelivery: startupCommand.startupCommandDelivery, + identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, + launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig, + signal: opts.signal + } + ) + } catch (error) { + // Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587). + if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { + const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) + if (rescued) { + this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) + return rescued + } + } + // Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718). + if ( + isClientDisconnectedError(error) || + this.hasLiveShellForRendererTab(worktreeId, reply.tabId) + ) { + throw error + } + // Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots. + this.notifier?.closeTerminal(reply.tabId) + throw error + } finally { + this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) + } + } finally { + releasePublicationThrottle() + } + } + + private async resolveMobileSessionTerminalCommand( + workspace: TerminalWorkspaceLaunchScope, + opts: { + command?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + agentPrompt?: string + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + } + ): Promise<{ + command?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + }> { + if (opts.command || !opts.agent) { + return { + command: opts.command, + env: opts.env, + envToDelete: opts.envToDelete, + launchConfig: opts.launchConfig, + launchAgent: opts.launchAgent, + startupCommandDelivery: opts.startupCommandDelivery + } + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. + const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace) + const queuedShell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startupPlan = buildAgentStartupPlan({ + agent: opts.agent, + prompt: opts.agentPrompt ?? '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv), + platform, + shell: queuedShell, + isRemote, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + throw new Error(`Could not build launch command for ${opts.agent}.`) + } + if (opts.agentPrompt && startupPlan.followupPrompt) { + throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) + } + await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path) + return { + command: startupPlan.launchCommand, + env: startupPlan.env, + // Why: a real-home Codex resume strips inherited CODEX_HOME via + // envToDelete; dropping it here would resume against the wrong home. + envToDelete: opts.envToDelete, + launchConfig: startupPlan.launchConfig, + launchAgent: opts.agent, + startupCommandDelivery: startupPlan.startupCommandDelivery + } + } + + private async createRuntimeOwnedMobileSessionTerminal( + worktreeId: string, + activate: boolean, + afterTabId?: string, + opts: { + command?: string + cwd?: string + env?: Record + envToDelete?: string[] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + identity?: { tabId: string; leafId: string; sessionId?: string } + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + targetGroupId?: string + launchConfig?: SleepingAgentLaunchConfig + signal?: AbortSignal + } = {} + ): Promise { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) + // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. + const stableSessionId = + opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) + const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined + const terminal = await this.createTerminal(`id:${worktreeId}`, { + focus: false, + command: opts.command, + cwd, + env: opts.env, + envToDelete: opts.envToDelete, + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.identity + ? { + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, + ...(stableSessionId ? { sessionId: stableSessionId } : {}) + } + : stableSessionId + ? { sessionId: stableSessionId } + : {}), + ...(isNewSession ? { isNewSession: true } : {}), + // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. + deferMobileSessionPublish: true, + signal: opts.signal + }) + const livePty = this.getLivePtyForHandle(terminal.handle) + if (!livePty) { + throw new Error('terminal_handle_stale') + } + const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` + const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() + if (opts.viewMode) { + // Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default. + this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) + } + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const existingSurface = + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === parentTabId && + candidate.leafId === leafId + ) ?? null + const parentLayout = this.buildMaterializedHeadlessParentLayout( + leafId, + livePty.pty.ptyId, + existingSurface?.parentLayout + ) + const tab: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + ptyId: livePty.pty.ptyId, + title: terminal.title ?? livePty.pty.title ?? 'Terminal', + ...(cwd ? { startupCwd: cwd } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + parentLayout, + isActive: activate + } + const tabs = (existing?.tabs ?? []) + .filter((candidate) => candidate.id !== tab.id) + .map((candidate) => ({ + ...candidate, + ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId + ? { parentLayout } + : {}), + isActive: activate ? false : candidate.isActive + })) + const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 + if (insertAfter >= 0) { + tabs.splice(insertAfter + 1, 0, tab) + } else { + tabs.push(tab) + } + const next: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: `headless:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + // Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too. + activeGroupId: + activate && opts.targetGroupId + ? opts.targetGroupId + : (existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId)), + activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), + activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), + tabGroups: this.buildHeadlessMobileSessionTabGroups( + worktreeId, + tabs, + activate ? tab : null, + existing?.tabGroups, + opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined + ), + // Why: keep group split geometry on new-tab creation, else opening a terminal while split loses the arrangement. + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, next) + const result = this.toMobileSessionTabsResult(next) + const changeSequence = ++this.mobileSessionTabsChangeSequence + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) + } + const created = result.tabs.find((candidate) => candidate.id === tab.id) + if (!created || created.type !== 'terminal') { + throw new Error('terminal_handle_stale') + } + return { + tab: created, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + } + + private waitForMobileTerminalSurface( + worktreeId: string, + parentTabId: string, + options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {} + ): Promise { + const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS + const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options) + if (existing) { + return Promise.resolve(existing) + } + if (options.signal?.aborted) { + return Promise.reject(new Error('client_disconnected')) + } + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + } + const timer = setTimeout(() => { + cleanup() + reject(new Error('Timed out waiting for terminal surface after creation')) + }, timeoutMs) + // Why: a dead client connection cancels the wait immediately instead of running down the timeout into rollback (#7718). + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + + const check = (): void => { + const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options) + if (!next) { + return + } + cleanup() + resolve(next) + } + this.graphSyncCallbacks.push(check) + check() + }) + } + + private findMobileTerminalSurface( + worktreeId: string, + parentTabId: string, + options: { requireReady?: boolean } = {} + ): RuntimeMobileSessionCreateTerminalResult | null { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return null + } + const result = this.toMobileSessionTabsResult(snapshot) + const tab = result.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + ) + if (!tab || tab.type !== 'terminal') { + return null + } + const surface = { + tab, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + if (options.requireReady === true && !this.isReadyMobileTerminalSurface(surface)) { + return null + } + return surface + } + + private findMobileTerminalSurfaceForPty( + worktreeId: string, + ptyId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return null + } + const result = this.toMobileSessionTabsResult(snapshot) + const tabs = result.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalClientTab => + candidate.type === 'terminal' && + (candidate.ptyId === ptyId || + candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === ptyId) + ) + if (tabs.length === 0 || new Set(tabs.map((tab) => tab.parentTabId)).size !== 1) { + return null + } + return { + tab: tabs[0]!, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + } + + private resolvePtyTabCloseSurfaceAuthority( + authority: RuntimePtyTabCloseAuthority + ): { pty: RuntimePtyWorktreeRecord; surface: RuntimeMobileSessionCreateTerminalResult } | null { + const live = this.getLivePtyForHandle(authority.handle) + if ( + !live || + live.pty.ptyId !== authority.ptyId || + live.pty.worktreeId !== authority.worktreeId || + live.record.worktreeId !== authority.worktreeId || + live.pty.incarnationId !== authority.incarnationId + ) { + return null + } + const surface = this.findMobileTerminalSurfaceForPty(authority.worktreeId, authority.ptyId) + if (!surface) { + return null + } + const session = this.getWorkspaceSessionForWorktree(authority.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, authority.worktreeId) + : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (tab) => tab.id === surface.tab.parentTabId + ) + : undefined + if (persistedTab && !worktreeIdsEqual(persistedTab.worktreeId, authority.worktreeId)) { + return null + } + const paneKey = makePaneKey(surface.tab.parentTabId, surface.tab.leafId) + const persistedPtyId = + session?.terminalLayoutsByTabId?.[surface.tab.parentTabId]?.ptyIdsByLeafId?.[ + surface.tab.leafId + ] ?? null + const persistedIncarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null + if ( + (persistedPtyId && persistedPtyId !== authority.ptyId) || + (persistedIncarnationId && persistedIncarnationId !== authority.incarnationId) + ) { + return null + } + if ( + !this.resolveTerminalSplitSourceAuthority( + authority.worktreeId, + surface.tab.parentTabId, + surface.tab.leafId, + authority.ptyId + ) + ) { + return null + } + return { pty: live.pty, surface } + } + + // Why: publish an in-flight mobile create main-side from the live PTY so it can't stall on graph sync and destroy the session (#7587). + private ensurePtyBackedMobileSurfaceForRendererTab( + worktreeId: string, + tabId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) + if (!pending) { + return null + } + const existing = this.findMobileTerminalSurface(worktreeId, tabId) + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + if (pty) { + pty.runtimeSessionOwned = true + if (pending.paired) { + this.setPairedRendererSessionOwnership(pty.ptyId, true) + } + } + if ( + existing && + this.isReadyMobileTerminalSurface(existing) && + (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) + ) { + // Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue. + return existing + } + const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined + if (!pty || !leafId) { + return existing + } + this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { + tabId, + leafId, + title: null, + activate: pending.activate, + selectIfNoActiveTab: pending.selectIfNoActiveTab, + ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) + }) + // Why: check closures normally drain only inside syncWindowGraph; a main-side publish must drain them too or the pending wait misses the insertion. + for (const cb of [...this.graphSyncCallbacks]) { + cb() + } + return this.findMobileTerminalSurface(worktreeId, tabId) + } + + private restoreLivePairedRendererSessionOwnedMobileTerminals( + worktreeId: string | null, + options: { missingSnapshotOnly?: boolean; notify?: boolean } = {} + ): void { + for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { + const pty = this.ptysById.get(ptyId) + if ( + !pty?.connected || + !pty.tabId || + (worktreeId !== null && !worktreeIdsEqual(pty.worktreeId, worktreeId)) + ) { + continue + } + const targetWorktreeId = worktreeId ?? pty.worktreeId + const pane = parsePaneKey(pty.paneKey ?? '') + if (!pane || pane.tabId !== pty.tabId) { + continue + } + const existing = this.mobileSessionTabsByWorktree.get(targetWorktreeId) + if (existing && options.missingSnapshotOnly) { + continue + } + if ( + existing?.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === pty.ptyId || + (tab.parentTabId === pty.tabId && tab.leafId === pane.leafId)) + ) + ) { + continue + } + if (!existing) { + this.mobileSessionTabsByWorktree.set(targetWorktreeId, { + worktree: targetWorktreeId, + publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`, + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabGroups: [], + tabs: [] + }) + } + this.publishPtyBackedMobileSessionTerminal(targetWorktreeId, pty, { + tabId: pty.tabId, + leafId: pane.leafId, + title: null, + activate: false, + selectIfNoActiveTab: false, + notify: options.notify + }) + } + } + + private setPairedRendererSessionOwnership(ptyId: string, owned: boolean): void { + if (owned) { + this.pairedRendererSessionOwnedPtyIds.add(ptyId) + } else { + this.pairedRendererSessionOwnedPtyIds.delete(ptyId) + } + } + + private findLiveRegisteredPtyForRendererTab( + worktreeId: string, + tabId: string + ): RuntimePtyWorktreeRecord | null { + for (const pty of this.ptysById.values()) { + if ( + pty.worktreeId === worktreeId && + pty.tabId === tabId && + pty.connected && + parsePaneKey(pty.paneKey ?? '')?.leafId + ) { + return pty + } + } + return null + } + + // Why: looser rollback guard than findLiveRegisteredPtyForRendererTab — a shell without a registered pane key is still a real terminal the timeout must not kill (#7718). + private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean { + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) { + return true + } + } + return false + } + + private isReadyMobileTerminalSurface( + surface: RuntimeMobileSessionCreateTerminalResult | null + ): boolean { + return ( + surface?.tab.status === 'ready' && + typeof surface.tab.terminal === 'string' && + surface.tab.terminal.length > 0 + ) + } + + // Why: a create can settle over a renderer PTY that spawned without its + // startup command (the create's renderer stalled, #7587), silently binding + // the client to a plain shell under an agent tab forever — once the surface + // is ready, the activation-time materialize recovery (#7837) never runs + // (STA-3214). Spawn commands are recorded per PTY at spawn time, so a + // missing record on the locally registered live PTY proves the launch never + // ran; type it into the shell like the create would have. + private deliverPendingStartupCommandToBareRendererPty(worktreeId: string, tabId: string): void { + const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) + const command = pending?.startupCommand + if (!command) { + return + } + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { + return + } + if (this.ptyController?.write(pty.ptyId, command)) { + // Why: Enter rides its own write so a long command cannot swallow it. + this.ptyController.write(pty.ptyId, '\r') + this.noteTerminalSpawnCommand(pty.ptyId, command) + } + } + + private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { + const existing = this.resolveHandleForTab(tabId) + if (existing) { + return Promise.resolve(existing) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for terminal handle after creation')) + }, timeoutMs) + + const check = (): void => { + const handle = this.resolveHandleForTab(tabId) + if (handle) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(handle) + } + } + this.graphSyncCallbacks.push(check) + // Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up. + check() + }) + } + + // Why: mobile may subscribe before the PTY spawns; wait for it so subscribe proceeds with phone-fit instead of a bare scrollback+end. + waitForLeafPtyId(handle: string, timeoutMs = 10_000, signal?: AbortSignal): Promise { + const leaf = this.resolveLeafForHandle(handle) + if (leaf?.ptyId) { + return Promise.resolve(leaf.ptyId) + } + + // Why: ptyId null→real invalidates the old handle; capture tabId+leafId now for direct leaf lookup afterward. + const record = this.handles.get(handle) + const savedTabId = record?.tabId ?? null + const savedLeafId = record?.leafId ?? null + + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null + let check: () => void = () => {} + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + signal?.removeEventListener('abort', onAbort) + } + const finish = (ptyId: string): void => { + cleanup() + resolve(ptyId) + } + const fail = (error: Error): void => { + cleanup() + reject(error) + } + const onAbort = (): void => { + fail(new Error('request_aborted')) + } + if (signal?.aborted) { + reject(new Error('request_aborted')) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout(() => { + fail(new Error('Timed out waiting for PTY to spawn')) + }, timeoutMs) + + check = (): void => { + // Try the handle first (works if handle wasn't invalidated yet) + let ptyId = this.resolveLeafForHandle(handle)?.ptyId + // Why: ptyId null→real invalidates the old handle; fall back to direct leaf lookup by saved coordinates. + if (!ptyId && savedTabId && savedLeafId) { + const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) + ptyId = directLeaf?.ptyId ?? null + } + if (ptyId) { + finish(ptyId) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + + // Why: never-mounted tabs have no PTY or snapshot; synthetic handles need the ptyId to mount the exact owning tab. + requestRendererTerminalTabMount(handle: string): boolean { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return false + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return false + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + return true + } catch { + // No authoritative window (shutdown/headless): subscribe keeps its empty-snapshot fallback. + return false + } + } + + getRendererTerminalSerializerGeneration(ptyId: string): number { + return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 + } + + getRendererTerminalSerializerGenerationForHandle(handle: string): number { + const ptyId = this.handles.get(handle)?.ptyId + return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 + } + + replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId: string, + snapshot: { + data: string + cols: number + rows: number + cwd?: string | null + oscLinks?: TerminalOscLinkRange[] + }, + trailingOutput: { data: string; seq: number }[] = [] + ): void { + if (!snapshot.data) { + return + } + // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the exact snapshot already sent mobile. + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.seedHeadlessTerminal( + ptyId, + snapshot.data, + { cols: snapshot.cols, rows: snapshot.rows }, + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + ) + for (const chunk of trailingOutput) { + this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) + } + // The seed's write chain owns subsequent live bytes; suppress on-data hydration from replacing this known-good seed. + this.headlessHydrationState.set(ptyId, 'done') + } + + waitForRendererTerminalSerializer( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + return ( + this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? + Promise.resolve(false) + ) + } + + // Why: a leaf exists before its PTY spawns; a handle issued while ptyId is null gets invalidated on the next sync, so wait for a connected PTY. + private countLeavesInTab(tabId: string): number { + let count = 0 + for (const leaf of this.leaves.values()) { + if (leaf.tabId === tabId) { + count++ + } + } + return count + } + + private getPtyIdsForExplicitTabClose(worktreeId: string, tabId: string): string[] { + const ptyIds = new Set() + for (const pty of this.ptysById.values()) { + if (pty.connected && pty.worktreeId === worktreeId && pty.tabId === tabId) { + ptyIds.add(pty.ptyId) + } + } + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === worktreeId && leaf.tabId === tabId && leaf.ptyId) { + ptyIds.add(leaf.ptyId) + } + } + return [...ptyIds] + } + + private async stopExplicitlyClosedTabPtys( + ptyIds: readonly string[], + addressedPtyId: string + ): Promise { + let addressedPtyStopped = false + const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS + for (const ptyId of ptyIds) { + // Why here: this is the single funnel for an explicit close, and the + // intent must be on record before the stop, since the provider may report + // the exit itself with a status that reads like a natural finish. + this.markPtyStopRequested(ptyId) + let stopped = false + if (this.ptyController?.stopAndWait) { + try { + stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs }) + } catch (error) { + this.markPtyLivenessUnverifiable( + ptyId, + error instanceof Error ? error.message : String(error) + ) + } + if (!stopped) { + const verdict = this.getPtyLivenessVerdict(ptyId) + const providerAlreadyRetiredPty = + verdict?.status === 'unverifiable' && + verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON + if (!providerAlreadyRetiredPty) { + this.ptyController.kill(ptyId) + if (!verdict || verdict.status === 'live') { + this.markPtyLivenessUnverifiable( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + } + } + } + } else { + stopped = this.ptyController?.kill(ptyId) ?? false + } + if (ptyId === addressedPtyId) { + addressedPtyStopped = stopped + } + } + return addressedPtyStopped + } + + private resolveHandleForTab(tabId: string): string | null { + for (const leaf of this.leaves.values()) { + if (leaf.tabId === tabId && leaf.ptyId !== null) { + return this.issueHandle(leaf) + } + } + return null + } + + async focusTerminal( + handle: string, + options: { navigateHost?: boolean } = {} + ): Promise { + const navigateHost = options.navigateHost !== false + const livePtyIdentity = (): RuntimeTerminalFocus => { + const live = this.getLivePtyForHandle(handle) + if (!live?.pty.connected) { + throw new Error('terminal_exited') + } + return { + handle, + tabId: live.pty.tabId ?? live.record.tabId, + worktreeId: live.pty.worktreeId, + navigated: false + } + } + const liveLeafIdentity = (): RuntimeTerminalFocus => { + this.assertGraphReady() + const { leaf: current } = this.getLiveLeafForHandle(handle) + return { + handle, + tabId: current.tabId, + worktreeId: current.worktreeId, + navigated: false + } + } + + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_exited') + } + if (!navigateHost || !this.notifier?.revealTerminalSession) { + return { + handle, + tabId: pty.pty.tabId ?? pty.record.tabId, + worktreeId: pty.pty.worktreeId, + navigated: false + } + } + // Coalesce concurrent host navigations: only the latest full reveal claims navigated. + return this.terminalFocusNavigationCoalescer.run({ + key: handle, + resolveSuperseded: (completed) => + completed ? { ...completed, navigated: false } : livePtyIdentity(), + run: async (ctx) => { + const live = this.getLivePtyForHandle(handle) + if (!live?.pty.connected) { + throw new Error('terminal_exited') + } + if (!ctx.isCurrent()) { + return { + handle, + tabId: live.pty.tabId ?? live.record.tabId, + worktreeId: live.pty.worktreeId, + navigated: false + } + } + const notifier = this.notifier + if (!notifier?.revealTerminalSession) { + return { + handle, + tabId: live.pty.tabId ?? live.record.tabId, + worktreeId: live.pty.worktreeId, + navigated: false + } + } + const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') + const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { + ptyId: live.pty.ptyId, + title: getLatestPtyTitle(live.pty), + ...(live.pty.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } + : {}), + ...(live.pty.launchToken ? { launchToken: live.pty.launchToken } : {}), + ...(live.pty.launchAgent ? { launchAgent: live.pty.launchAgent } : {}), + ...(live.pty.tabId !== null ? { tabId: live.pty.tabId } : {}), + ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) + }) + if (!ctx.isCurrent() || this.notifier !== notifier) { + return { + handle, + tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, + worktreeId: live.pty.worktreeId, + navigated: false + } + } + return { + handle, + tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, + worktreeId: live.pty.worktreeId, + navigated: true + } + } + }) + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + if (!navigateHost) { + return { + handle, + tabId: leaf.tabId, + worktreeId: leaf.worktreeId, + navigated: false + } + } + if (!this.notifier?.focusTerminal) { + return { + handle, + tabId: leaf.tabId, + worktreeId: leaf.worktreeId, + navigated: false + } + } + return this.terminalFocusNavigationCoalescer.run({ + key: handle, + resolveSuperseded: (completed) => + completed ? { ...completed, navigated: false } : liveLeafIdentity(), + run: async (ctx) => { + this.assertGraphReady() + const { leaf: liveLeaf } = this.getLiveLeafForHandle(handle) + if (!ctx.isCurrent()) { + return { + handle, + tabId: liveLeaf.tabId, + worktreeId: liveLeaf.worktreeId, + navigated: false + } + } + const notifier = this.notifier + if (!notifier?.focusTerminal) { + return { + handle, + tabId: liveLeaf.tabId, + worktreeId: liveLeaf.worktreeId, + navigated: false + } + } + notifier.focusTerminal(liveLeaf.tabId, liveLeaf.worktreeId, liveLeaf.leafId) + if (!ctx.isCurrent() || this.notifier !== notifier) { + return { + handle, + tabId: liveLeaf.tabId, + worktreeId: liveLeaf.worktreeId, + navigated: false + } + } + return { + handle, + tabId: liveLeaf.tabId, + worktreeId: liveLeaf.worktreeId, + navigated: true + } + } + }) + } + + async closeTerminal(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + if (pty) { + const closeAuthority: RuntimePtyTabCloseAuthority = { + handle, + ptyId: pty.pty.ptyId, + incarnationId: pty.pty.incarnationId, + worktreeId: pty.pty.worktreeId + } + const ptyCloseAuthority = this.resolvePtyTabCloseSurfaceAuthority(closeAuthority) + const spawnSurface = pty.pty.tabId + ? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId) + : null + // Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it. + const surface = + ptyCloseAuthority?.surface ?? + (spawnSurface && this.getMobileTerminalLeafPtyIds(spawnSurface.tab).length === 0 + ? spawnSurface + : null) + const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId + // Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface. + const siblingCount = surface?.tab.parentLayout + ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) + : this.countLeavesInTab(tabId) + if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) { + const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) + try { + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + localPtyTeardownOwnedExternally: true + }) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error + } + this.notifier.closeTerminal?.(tabId) + } + const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + } + if ( + siblingCount <= 1 && + surface && + ptyCloseAuthority && + !this.tabs.has(surface.tab.parentTabId) + ) { + try { + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + reason: 'user', + localPtyTeardownOwnedExternally: true, + expectedPtyCloseAuthority: closeAuthority + }) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error + } + const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + this.notifier?.closeTerminal(tabId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + } + const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + } + if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { + const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) + await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) + const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + } + const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + if (!ptyKilled || siblingCount <= 1) { + if (surface) { + // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. + try { + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error + } + this.notifier?.closeTerminal(tabId) + } + } else { + this.notifier?.closeTerminal(tabId) + } + } + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + // Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab. + const siblingCount = this.countLeavesInTab(leaf.tabId) + const ptyIdsToKill = + siblingCount <= 1 + ? this.getPtyIdsForExplicitTabClose(leaf.worktreeId, leaf.tabId) + : leaf.ptyId + ? [leaf.ptyId] + : [] + if (siblingCount <= 1 && this.notifier?.closeTerminalTab) { + await this.notifier.closeTerminalTab(leaf.tabId, { + localPtyTeardownOwnedExternally: true + }) + } + const ptyKilled = leaf.ptyId + ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId) + : false + if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) { + this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) + } + return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, ptyKilled) + } + + /** + * A close receipt must not read as a kill nobody performed: when the stop was + * not confirmed, the receipt carries why so the CLI and callers can say so. + */ + private describeTerminalClose( + handle: string, + tabId: string, + ptyId: string | null, + ptyKilled: boolean + ): RuntimeTerminalClose { + if (ptyKilled || !ptyId) { + return { handle, tabId, ptyKilled } + } + const verdict = this.getPtyLivenessVerdict(ptyId) + if (verdict?.status === 'unverifiable') { + return { + handle, + tabId, + ptyKilled, + ptyStopVerdict: 'unverifiable', + ptyStopReason: verdict.reason + } + } + if (verdict?.status === 'live') { + return { handle, tabId, ptyKilled, ptyStopVerdict: 'live' } + } + return { handle, tabId, ptyKilled } + } + + async closeTerminalTab(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const closeAuthority: RuntimePtyTabCloseAuthority = { + handle, + ptyId: pty.pty.ptyId, + incarnationId: pty.pty.incarnationId, + worktreeId: pty.pty.worktreeId + } + const tabId = + this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? + pty.pty.tabId + if (!tabId) { + return this.closeTerminal(handle) + } + // Why: a handle-addressed CLI/automation close is an explicit intent, so + // it must stay destructive under the non-user close adjudication gate. + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + reason: 'user', + expectedPtyCloseAuthority: closeAuthority + }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } + } + + async splitTerminal( + handle: string, + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + envToDelete?: string[] + activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false + telemetrySource?: TerminalPaneSplitSource + } = {} + ): Promise { + const livePty = this.getLivePtyForHandle(handle) + if (livePty) { + return await this.splitPtyBackedTerminal(livePty.pty, opts) + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + const direction = opts.direction ?? 'horizontal' + + const newLeafId = randomUUID() + + this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { + direction, + command: opts.command, + worktreeId: leaf.worktreeId, + sourceLeafId: leaf.leafId, + telemetrySource: opts.telemetrySource, + newLeafId + }) + + const newHandle = await this.waitForLeafInTab(leaf.tabId, newLeafId) + return { + handle: newHandle, + tabId: leaf.tabId, + paneRuntimeId: leaf.paneRuntimeId, + leafId: newLeafId + } + } + + private async splitPtyBackedTerminal( + pty: RuntimePtyWorktreeRecord, + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + envToDelete?: string[] + activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false + telemetrySource?: TerminalPaneSplitSource + } = {} + ): Promise { + if (!this.ptyController?.spawn) { + throw new Error('runtime_unavailable') + } + if (!pty.connected) { + throw new Error('terminal_exited') + } + const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') + const parentTabId = pty.tabId?.trim() + if (!parentTabId || !parsedPaneKey) { + throw new Error('terminal_handle_stale') + } + const direction = opts.direction ?? 'horizontal' + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) + const sourceAuthority = this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + if (!sourceAuthority) { + throw new Error('terminal_split_source_not_found') + } + const sourceIncarnationId = + sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId + const leafId = randomUUID() + const preAllocatedHandle = this.createPreAllocatedTerminalHandle() + const paneKey = makePaneKey(parentTabId, leafId) + const result = await this.ptyController.spawn({ + cols: 120, + rows: 40, + cwd: workspace.path, + command: opts.command, + commandDelivery: 'provider', + env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), + envToDelete: opts.envToDelete, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId: parentTabId, + leafId, + persistHostSessionBinding: true, + ...(sourceAuthority.persisted + ? { + expectedSourceBinding: { + ...(sourceAuthority.persistedWorktreeId + ? { worktreeId: sourceAuthority.persistedWorktreeId } + : {}), + tabId: parentTabId, + leafId: parsedPaneKey.leafId, + ptyId: pty.ptyId, + // Why: the store can only match its own persisted map, so a live-only id it never + // recorded would reject every split from a session restored without incarnations. + // The live id is fenced by revalidateSourceAuthority below instead. + ...(sourceAuthority.persistedIncarnationId + ? { incarnationId: sourceAuthority.persistedIncarnationId } + : {}) + } + } + : {}) + }) + this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } + this.registerPty(result.id, workspace.id, workspace.connectionId) + const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) + if (createdPty) { + createdPty.tabId = parentTabId + createdPty.paneKey = paneKey + createdPty.runtimeSessionOwned = pty.runtimeSessionOwned + this.setPairedRendererSessionOwnership( + createdPty.ptyId, + this.pairedRendererSessionOwnedPtyIds.has(pty.ptyId) + ) + } + + const revealSplit = async (): Promise => { + await this.notifier?.revealTerminalSession?.(workspace.id, { + ptyId: result.id, + title: null, + activate: opts.activate !== false, + ...ownerSurfacing(opts.surfaceOwner !== false), + tabId: parentTabId, + leafId, + splitFromLeafId: parsedPaneKey.leafId, + splitDirection: direction, + splitTelemetrySource: opts.telemetrySource + }) + } + + try { + const revalidateSourceAuthority = (): void => { + const current = this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + if ( + !current || + (sourceAuthority.persisted && !current.persisted) || + (sourceIncarnationId !== null && + (current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId) + ) { + throw new Error('terminal_split_source_not_found') + } + } + revalidateSourceAuthority() + if (!sourceAuthority.persisted) { + await revealSplit() + // Why: rejecting here unmounts the pane the reveal just added only because the retire + // below always emits its exit and the tab still holds the source sibling — the renderer's + // exit handler closes non-final panes. Never close it by tabId: that drops the whole tab. + revalidateSourceAuthority() + } + if (createdPty) { + const persisted = this.persistHeadlessTerminalSplit({ + worktreeId: workspace.id, + tabId: parentTabId, + leafId, + ptyId: createdPty.ptyId, + splitFromLeafId: parsedPaneKey.leafId, + direction + }) + if (sourceAuthority.persisted && !persisted) { + throw new Error('workspace_session_unavailable') + } + this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { + tabId: parentTabId, + leafId, + title: null, + activate: opts.activate !== false, + split: { splitFromLeafId: parsedPaneKey.leafId, direction } + }) + } + } catch (error) { + this.setPairedRendererSessionOwnership(result.id, false) + let stopped = false + try { + stopped = + (await this.ptyController.stopAndWait?.(result.id, { + deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS + })) ?? false + } catch { + // Best-effort fallback below preserves the original split authority error. + } + if (!stopped) { + try { + this.ptyController.kill(result.id) + } catch { + // Best-effort cleanup; retirement below still runs and the original error still throws. + } + } + try { + this.ptyController.retireRejectedPty?.(result.id, stopped) + } catch { + // Best-effort cleanup; preserve the original split authority error. + } + throw error + } + const committedSourceAuthority = sourceAuthority.persisted + ? this.resolveTerminalSplitSourceAuthority( + workspace.id, + parentTabId, + parsedPaneKey.leafId, + pty.ptyId + ) + : null + if (sourceAuthority.persisted && committedSourceAuthority?.rendererMounted) { + // Why: renderer adoption is a projection after the durable main commit; rejection cannot undo it. + void revealSplit().catch(() => undefined) + } + + return { + handle: this.issuePtyHandle(createdPty ?? pty), + tabId: parentTabId, + paneRuntimeId: -1, + leafId + } + } + + private resolveTerminalSplitSourceAuthority( + worktreeId: string, + tabId: string, + leafId: string, + ptyId: string + ): { + persisted: boolean + rendererMounted: boolean + persistedWorktreeId: string | null + persistedIncarnationId: string | null + liveIncarnationId: string | null + } | null { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (tab) => tab.id === tabId && worktreeIdsEqual(tab.worktreeId, worktreeId) + ) + : undefined + const persistedLayout = session?.terminalLayoutsByTabId?.[tabId] + const persistedIncarnationId = + session?.terminalPtyIncarnationsByPaneKey?.[makePaneKey(tabId, leafId)] ?? null + const liveIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? null + if ( + persistedIncarnationId && + liveIncarnationId && + persistedIncarnationId !== liveIncarnationId + ) { + return null + } + const persisted = Boolean( + persistedTab && + persistedLayout?.ptyIdsByLeafId?.[leafId] === ptyId && + terminalLayoutContainsLeaf(persistedLayout.root, leafId) + ) + const rendererTab = this.tabs.get(tabId) + const rendererLeaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + const rendererMounted = Boolean( + rendererTab && + rendererLeaf && + worktreeIdsEqual(rendererTab.worktreeId, worktreeId) && + worktreeIdsEqual(rendererLeaf.worktreeId, worktreeId) && + rendererLeaf.ptyId === ptyId + ) + if (persisted && persistedLayout) { + return { + persisted: true, + rendererMounted, + persistedWorktreeId: sessionWorktreeId, + persistedIncarnationId, + liveIncarnationId + } + } + // Why: renderer adoption can precede graph sync; this path still requires reveal success before commit. + const projected = [...this.mobileSessionTabsByWorktree.entries()].some( + ([candidateWorktreeId, snapshot]) => + worktreeIdsEqual(candidateWorktreeId, worktreeId) && + snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + tab.parentTabId === tabId && + tab.leafId === leafId && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[leafId] === ptyId) + ) + ) + if (!rendererMounted && !projected) { + return null + } + return { + persisted: false, + rendererMounted, + persistedWorktreeId: null, + persistedIncarnationId: null, + liveIncarnationId + } + } + + async handleAgentTeamsTmuxCompat( + request: AgentTeamsTmuxCompatRequest + ): Promise { + return await this.claudeAgentTeams.handleTmuxCompat(request, { + splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), + readTerminal: (handle, opts) => this.readTerminal(handle, opts), + sendTerminal: (handle, action) => this.sendTerminal(handle, action), + focusTerminal: (handle) => this.focusTerminal(handle), + closeTerminal: (handle) => this.closeTerminal(handle), + showTerminal: (handle) => this.showTerminal(handle) + }) + } + + async prepareClaudeAgentTeamsLeader(args: { + paneKey: string + baseEnv?: Record + }): Promise<{ env: Record }> { + const handle = this.getTerminalHandleForPaneKey(args.paneKey) + if (!handle) { + throw new Error('claude_agent_teams_requires_orca_terminal') + } + return await this.prepareClaudeAgentTeamsLeaderForHandle({ + handle, + baseEnv: args.baseEnv + }) + } + + async prepareClaudeAgentTeamsLeaderForHandle(args: { + handle: string + baseEnv?: Record + }): Promise<{ env: Record }> { + const baseEnv = { + ...process.env, + ...args.baseEnv + } + const shimDir = await ensureClaudeAgentTeamsShimDir() + const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) + return this.claudeAgentTeams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv, + shimDir, + shimBin + }) + } + + // Why: a leader handle that never binds to a PTY (lost pane race) has no exit + // or close path to evict its team, so the abandoning caller must release it. + releaseClaudeAgentTeamsLeaderForHandle(handle: string): void { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + } + + private waitForLeafInTab(tabId: string, leafId: string, timeoutMs = 10_000): Promise { + const tryResolve = (): string | null => { + const leaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + return leaf?.ptyId !== null && leaf?.ptyId !== undefined ? this.issueHandle(leaf) : null + } + + const existing = tryResolve() + if (existing) { + return Promise.resolve(existing) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for split pane handle')) + }, timeoutMs) + + const check = (): void => { + const handle = tryResolve() + if (handle) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(handle) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + + async stopTerminalsForWorktree( + worktreeSelector: string, + options: { + deadline?: number + stopPty?: ( + ptyId: string, + stop: () => boolean | Promise + ) => Promise<{ stopped: boolean; owner: boolean }> + /** Authoritative id for an orphan whose selector no longer resolves. */ + resolvedWorktreeId?: string + resolvedConnectionId?: string + resolvedRuntimeEnvironmentId?: string + } = {} + ): Promise<{ stopped: number }> { + // Why: this mutates live PTYs, so reject while the graph is reloading rather than act on cached leaf ownership. + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = options.resolvedWorktreeId + ? { id: options.resolvedWorktreeId } + : await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + if (options.deadline !== undefined && Date.now() >= options.deadline) { + return { stopped: 0 } + } + // Preserve folder-instance suffixes while normalizing cross-platform path spelling. + const ownsWorktree = options.resolvedWorktreeId + ? (candidate: string | undefined): boolean => + candidate ? worktreeIdsEqual(candidate, worktree.id) : false + : (candidate: string | undefined): boolean => candidate === worktree.id + const ownsHost = (ptyId: string, connectionId?: string | null): boolean => { + if (options.resolvedRuntimeEnvironmentId !== undefined) { + return ptyId.startsWith( + `remote:${encodeURIComponent(options.resolvedRuntimeEnvironmentId)}@@` + ) + } + return ( + options.resolvedConnectionId === undefined || connectionId === options.resolvedConnectionId + ) + } + const ptyIds = new Set() + for (const leaf of this.leaves.values()) { + if ( + ownsWorktree(leaf.worktreeId) && + leaf.ptyId && + ownsHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId) + ) { + ptyIds.add(leaf.ptyId) + } + } + for (const pty of this.ptysById.values()) { + if (ownsWorktree(pty.worktreeId) && pty.connected && ownsHost(pty.ptyId, pty.connectionId)) { + ptyIds.add(pty.ptyId) + } + } + + let stopped = 0 + for (const ptyId of ptyIds) { + if (options.deadline !== undefined && Date.now() >= options.deadline) { + break + } + const stop = (): boolean | Promise => { + if (options.deadline !== undefined && Date.now() >= options.deadline) { + return false + } + if (options.stopPty) { + // Why: destructive worktree cleanup must not let its cross-surface + // dedupe treat fire-and-forget controller.kill as physical exit. + // Why: the RPC deadline makes shutdown/list RPCs settle before the sweep + // deadline so a wedged daemon yields the accurate stop failure; no deadline + // (non-destructive) keeps the provider default RPC timeout. + if (options.deadline !== undefined) { + return ( + this.ptyController?.stopAndWait?.(ptyId, { + deadlineMs: teardownRpcDeadline(options.deadline) + }) ?? false + ) + } + return this.ptyController?.stopAndWait?.(ptyId) ?? false + } + return Boolean(this.ptyController?.kill(ptyId)) + } + const stopResult = options.stopPty + ? await options.stopPty(ptyId, stop) + : { stopped: stop(), owner: true } + if (stopResult.owner && stopResult.stopped) { + stopped += 1 + } + } + return { stopped } + } + + async sleepTerminalsForWorktree( + worktreeSelector: string + ): Promise { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const existing = this.terminalSleepByWorktreeId.get(worktree.id) + if (existing) { + return await existing + } + + const sleeping = this.sleepResolvedWorktreeTerminals(worktree) + this.terminalSleepByWorktreeId.set(worktree.id, sleeping) + try { + return await sleeping + } finally { + if (this.terminalSleepByWorktreeId.get(worktree.id) === sleeping) { + this.terminalSleepByWorktreeId.delete(worktree.id) + } + } + } + + async acquireWorktreeTerminalSpawn(worktreeId?: string): Promise<() => void> { + if (!worktreeId) { + return () => {} + } + const release = await this.acquireWorktreeTerminalMutation(worktreeId) + const key = runtimeWorktreeIdentityKey(worktreeId) + const sleepState = this.terminalSleepStateByWorktreeId.get(key) + if (sleepState?.phase === 'sleeping' || sleepState?.phase === 'partial') { + this.terminalSleepStateByWorktreeId.delete(key) + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: sleepState.worktreeId, + generation: sleepState.generation, + phase: 'woken', + ptyIds: sleepState.ptyIds, + terminalHandles: sleepState.terminalHandles + }) + } + return release + } + + private async runWorktreeTerminalMutation( + worktreeId: string, + operation: () => Promise + ): Promise { + const release = await this.acquireWorktreeTerminalMutation(worktreeId) + try { + return await operation() + } finally { + release() + } + } + + private async acquireWorktreeTerminalMutation( + worktreeId: string, + deadline?: number + ): Promise<() => void> { + const key = runtimeWorktreeIdentityKey(worktreeId) + const previous = this.terminalMutationTailByWorktreeId.get(key) ?? Promise.resolve() + let releaseCurrent = (): void => {} + const current = new Promise((resolve) => { + releaseCurrent = resolve + }) + const tail = previous.catch(() => {}).then(() => current) + this.terminalMutationTailByWorktreeId.set(key, tail) + try { + await waitForWorktreeTerminalMutation( + previous.catch(() => {}), + deadline + ) + } catch (error) { + // Why: resolve this abandoned queue node now so it can never acquire later and stop a terminal after the caller timed out. + releaseCurrent() + void tail.finally(() => { + if (this.terminalMutationTailByWorktreeId.get(key) === tail) { + this.terminalMutationTailByWorktreeId.delete(key) + } + }) + throw error + } + let released = false + return () => { + if (released) { + return + } + released = true + releaseCurrent() + void tail.finally(() => { + if (this.terminalMutationTailByWorktreeId.get(key) === tail) { + this.terminalMutationTailByWorktreeId.delete(key) + } + }) + } + } + + private async sleepResolvedWorktreeTerminals( + worktree: ResolvedWorktree + ): Promise { + const sleepDeadline = Date.now() + WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS + const releaseMutation = await this.acquireWorktreeTerminalMutation(worktree.id, sleepDeadline) + const key = runtimeWorktreeIdentityKey(worktree.id) + const existingSleepState = this.terminalSleepStateByWorktreeId.get(key) + if (existingSleepState?.phase === 'sleeping') { + try { + const resolvedWorktrees = includeTargetResolvedWorktree( + [...(await this.getResolvedWorktreeMap()).values()], + worktree + ) + const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + worktree.id, + sleepDeadline + ) + if (!refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + if (this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness).size === 0) { + releaseMutation() + return { + stopped: 0, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + } + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: existingSleepState.worktreeId, + generation: existingSleepState.generation, + phase: 'woken', + ptyIds: existingSleepState.ptyIds, + terminalHandles: existingSleepState.terminalHandles + }) + this.terminalSleepStateByWorktreeId.delete(key) + } catch (error) { + releaseMutation() + throw error + } + } + const priorPartialState = existingSleepState?.phase === 'partial' ? existingSleepState : null + const committedPtyIds = new Set(priorPartialState?.ptyIds ?? []) + const terminalHandlesByPtyId = { ...priorPartialState?.terminalHandlesByPtyId } + const pendingPtyIds = new Set() + let generation = 0 + let fullyCommitted = false + let releaseReversibleRendererStops = (): void => {} + try { + const resolvedWorktrees = includeTargetResolvedWorktree( + [...(await this.getResolvedWorktreeMap()).values()], + worktree + ) + const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + worktree.id, + sleepDeadline + ) + if (!refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) + generation = ++this.terminalSleepGeneration + for (const ptyId of livePtyIds) { + pendingPtyIds.add(ptyId) + terminalHandlesByPtyId[ptyId] = this.getTerminalHandlesForPtyId(ptyId) + } + const liveTerminalHandles = this.getRecordedTerminalSleepHandles( + livePtyIds, + terminalHandlesByPtyId + ) + this.terminalSleepStateByWorktreeId.set(key, { + worktreeId: worktree.id, + generation, + phase: 'stopping', + ptyIds: [...committedPtyIds].sort(), + terminalHandles: this.getRecordedTerminalSleepHandles( + committedPtyIds, + terminalHandlesByPtyId + ), + terminalHandlesByPtyId + }) + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: worktree.id, + generation, + phase: 'started', + ptyIds: [...livePtyIds].sort(), + terminalHandles: liveTerminalHandles + }) + if (committedPtyIds.size > 0) { + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: worktree.id, + generation, + phase: 'committed', + ptyIds: [...committedPtyIds].sort(), + terminalHandles: this.getRecordedTerminalSleepHandles( + committedPtyIds, + terminalHandlesByPtyId + ) + }) + } + if (livePtyIds.size === 0) { + const terminalHandles = this.getRecordedTerminalSleepHandles( + committedPtyIds, + terminalHandlesByPtyId + ) + this.terminalSleepStateByWorktreeId.set(key, { + worktreeId: worktree.id, + generation, + phase: 'sleeping', + ptyIds: [...committedPtyIds].sort(), + terminalHandles, + terminalHandlesByPtyId + }) + fullyCommitted = true + return { + stopped: 0, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + } + const ptyController = this.ptyController + if (!ptyController?.stopAndWait) { + throw new Error('terminal_worktree_sleep_unavailable') + } + const stopAndWait = ptyController.stopAndWait.bind(ptyController) + + const orderedLivePtyIds = [...livePtyIds].sort() + releaseReversibleRendererStops = + ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => ({ + ptyId, + stopped: await stopAndWait(ptyId, { + keepHistory: true, + deadlineMs: teardownRpcDeadline(sleepDeadline) + }) + })) + ) + const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { + const result = stopResults[index] + return result?.status === 'fulfilled' && result.value.stopped + }) + const failedStopIndex = stopResults.findIndex((result) => + result.status === 'rejected' ? true : !result.value.stopped + ) + + const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( + resolvedWorktrees, + worktree.id, + sleepDeadline + ) + if (!postStopLiveness) { + this.commitWorktreeTerminalSleepPtys({ + worktreeId: worktree.id, + generation, + ptyIds: successfulStopPtyIds, + pendingPtyIds, + committedPtyIds, + terminalHandlesByPtyId + }) + if (failedStopIndex !== -1) { + const failedStop = stopResults[failedStopIndex] + throw Object.assign(new Error('terminal_worktree_sleep_failed'), { + ptyId: orderedLivePtyIds[failedStopIndex], + ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) + }) + } + return { + stopped: successfulStopPtyIds.length, + stoppedPtyIds: successfulStopPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_liveness_unavailable' + } + } + const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) + const provenStoppedPtyIds = orderedLivePtyIds.filter( + (ptyId) => !remainingLivePtyIds.has(ptyId) + ) + this.commitWorktreeTerminalSleepPtys({ + worktreeId: worktree.id, + generation, + ptyIds: provenStoppedPtyIds, + pendingPtyIds, + committedPtyIds, + terminalHandlesByPtyId + }) + if (failedStopIndex !== -1 && remainingLivePtyIds.size > 0) { + const failedStop = stopResults[failedStopIndex] + console.error('[runtime] worktree terminal sleep physical stop failed', { + worktreeId: worktree.id, + ptyId: orderedLivePtyIds[failedStopIndex], + cause: failedStop.status === 'rejected' ? failedStop.reason : 'stop_not_acknowledged' + }) + throw Object.assign(new Error('terminal_worktree_sleep_failed'), { + ptyId: orderedLivePtyIds[failedStopIndex], + remainingLivePtyIds: [...remainingLivePtyIds].sort(), + ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) + }) + } + if (remainingLivePtyIds.size > 0) { + return { + stopped: successfulStopPtyIds.length, + stoppedPtyIds: successfulStopPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_worktree_sleep_still_live', + remainingLivePtyIds: [...remainingLivePtyIds].sort() + } + } + const terminalHandles = this.getRecordedTerminalSleepHandles( + committedPtyIds, + terminalHandlesByPtyId + ) + this.terminalSleepStateByWorktreeId.set(key, { + worktreeId: worktree.id, + generation, + phase: 'sleeping', + ptyIds: [...committedPtyIds].sort(), + terminalHandles, + terminalHandlesByPtyId + }) + fullyCommitted = true + return { + stopped: provenStoppedPtyIds.length, + stoppedPtyIds: provenStoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: true + } + } finally { + releaseReversibleRendererStops() + if (!fullyCommitted && generation > 0) { + const cancelledPtyIds = [...pendingPtyIds].sort() + if (cancelledPtyIds.length > 0) { + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: worktree.id, + generation, + phase: 'cancelled', + ptyIds: cancelledPtyIds, + terminalHandles: this.getRecordedTerminalSleepHandles( + cancelledPtyIds, + terminalHandlesByPtyId + ) + }) + } + if (committedPtyIds.size > 0) { + const terminalHandles = this.getRecordedTerminalSleepHandles( + committedPtyIds, + terminalHandlesByPtyId + ) + this.terminalSleepStateByWorktreeId.set(key, { + worktreeId: worktree.id, + generation, + phase: 'partial', + ptyIds: [...committedPtyIds].sort(), + terminalHandles, + terminalHandlesByPtyId + }) + } else { + this.terminalSleepStateByWorktreeId.delete(key) + } + } + releaseMutation() + } + } + + async stopExactTerminalsForWorktree( + worktreeSelector: string, + expectedPtyIds: readonly string[], + opts: { keepHistory?: boolean; targetOnly?: boolean } = {} + ): Promise<{ + stopped: number + stoppedPtyIds: string[] + livePtyIds: string[] + postStopVerified: boolean + postStopFailure?: string + remainingLivePtyIds?: string[] + }> { + // Why: exact stop hibernates one known pane; worktree sleep discovers its complete host-owned set separately. + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0)) + if (expected.size !== 1) { + throw new Error('terminal_exact_stop_requires_single_pty') + } + const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] + const refreshedPtyLiveness = + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + if (!refreshedPtyLiveness) { + throw new Error('terminal_liveness_unavailable') + } + const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) + const targetOnly = opts.targetOnly === true + const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) + if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { + const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { + livePtyIds: [...livePtyIds].sort(), + expectedPtyIds: [...expected].sort() + }) + throw error + } + + if (!this.ptyController?.stopAndWait) { + throw new Error('terminal_exact_stop_unavailable') + } + + const stoppedPtyIds: string[] = [] + for (const ptyId of [...expected].sort()) { + if (opts.keepHistory) { + this.intentionalHandlelessPtyStops.set( + ptyId, + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + } + try { + if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) + } + } finally { + this.intentionalHandlelessPtyStops.delete(ptyId) + } + stoppedPtyIds.push(ptyId) + } + const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + if (!postStopLiveness) { + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_liveness_unavailable' + } + } + const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) + const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) + if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: false, + postStopFailure: 'terminal_exact_stop_still_live', + remainingLivePtyIds: [...remainingLivePtyIds].sort() + } + } + return { + stopped: stoppedPtyIds.length, + stoppedPtyIds, + livePtyIds: [...livePtyIds].sort(), + postStopVerified: true, + ...(targetOnly && remainingLivePtyIds.size > 0 + ? { remainingLivePtyIds: [...remainingLivePtyIds].sort() } + : {}) + } + } + + private getLivePtyIdsForWorktree( + worktreeId: string, + freshPtyIds?: ReadonlySet + ): Set { + const ptyIds = new Set() + for (const leaf of this.leaves.values()) { + if ( + worktreeIdsEqual(leaf.worktreeId, worktreeId) && + leaf.connected && + leaf.ptyId && + (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) + ) { + ptyIds.add(leaf.ptyId) + } + } + for (const pty of this.ptysById.values()) { + if ( + worktreeIdsEqual(pty.worktreeId, worktreeId) && + pty.connected && + (!freshPtyIds || freshPtyIds.has(pty.ptyId)) + ) { + ptyIds.add(pty.ptyId) + } + } + return ptyIds + } + + private getTerminalHandlesForPtyId(ptyId: string): string[] { + const handles = new Set( + this.getLeavesForPty(ptyId) + .filter((candidate) => candidate.connected) + .map((leaf) => this.issueHandle(leaf)) + ) + const runtimeHandle = this.handleByPtyId.get(ptyId) + if (runtimeHandle) { + handles.add(runtimeHandle) + } + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (!pty) { + throw Object.assign(new Error('terminal_worktree_sleep_handle_unavailable'), { ptyId }) + } + if (handles.size === 0) { + handles.add(this.issuePtyHandle(pty)) + } + return [...handles].sort() + } + + private getRecordedTerminalSleepHandles( + ptyIds: Iterable, + terminalHandlesByPtyId: Readonly> + ): string[] { + return [...new Set([...ptyIds].flatMap((ptyId) => terminalHandlesByPtyId[ptyId] ?? []))].sort() + } + + private commitWorktreeTerminalSleepPtys(args: { + worktreeId: string + generation: number + ptyIds: readonly string[] + pendingPtyIds: Set + committedPtyIds: Set + terminalHandlesByPtyId: Readonly> + }): void { + const newlyCommittedPtyIds = [...new Set(args.ptyIds)] + .filter((ptyId) => !args.committedPtyIds.has(ptyId)) + .sort() + for (const ptyId of newlyCommittedPtyIds) { + args.pendingPtyIds.delete(ptyId) + args.committedPtyIds.add(ptyId) + } + if (newlyCommittedPtyIds.length === 0) { + return + } + this.emitClientEvent({ + type: 'worktreeTerminalSleepState', + worktreeId: args.worktreeId, + generation: args.generation, + phase: 'committed', + ptyIds: newlyCommittedPtyIds, + terminalHandles: this.getRecordedTerminalSleepHandles( + newlyCommittedPtyIds, + args.terminalHandlesByPtyId + ) + }) + } + + async hasTerminalsForWorktree(worktreeSelector: string): Promise { + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === worktree.id && leaf.ptyId) { + return true + } + } + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktree.id && pty.connected) { + return true + } + } + return false + } + + markRendererReloading(windowId: number): RuntimeRendererReloadFence | null { + if ( + windowId !== HEADLESS_RUNTIME_WINDOW_ID && + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + this.headlessGraphFallbackAvailable + ) { + this.attachWindow(windowId) + const revision = this.graphReloadLifecycle.getActiveRevision() + return this.authoritativeWindowId === windowId && revision !== null + ? { revision, recovery: 'headless' } + : null + } + if (windowId !== this.authoritativeWindowId) { + return null + } + if (this.graphStatus === 'reloading') { + return { + revision: this.graphReloadLifecycle.begin(windowId), + recovery: this.shouldRestoreHeadlessGraph(windowId) ? 'headless' : 'reloading' + } + } + if (this.graphStatus !== 'ready') { + return null + } + return { revision: this.beginGraphReload(windowId), recovery: 'renderer' } + } + + private beginGraphReload(windowId: number): number { + // Why: the rebuilt graph decides whether an incarnation survived; do not stale proven process identities before that comparison. + this.rendererGraphEpoch += 1 + this.graphStatus = 'reloading' + const revision = this.graphReloadLifecycle.begin(windowId) + this.setTerminalSideEffectConsumerAvailable(false) + this.rememberDetachedPreAllocatedLeaves() + const retainedHandles = new Set([ + ...this.handleByPtyId.values(), + ...[...this.handleByPtyIncarnation.values()].map((record) => record.handle) + ]) + for (const handle of this.waitersByHandle.keys()) { + if (!retainedHandles.has(handle)) { + this.rejectWaitersForHandle(handle, 'terminal_handle_stale') + } + } + this.handles.clear() + this.handleByLeafKey.clear() + this.refreshWritableFlags() + return revision + } + + markRendererReloadCancelled(windowId: number, fence: RuntimeRendererReloadFence): boolean { + if ( + windowId !== this.authoritativeWindowId || + this.graphStatus !== 'reloading' || + !this.graphReloadLifecycle.settle(fence.revision, 'cancelled') + ) { + return false + } + if (fence.recovery === 'headless' && this.shouldRestoreHeadlessGraph(windowId)) { + this.restoreHeadlessGraphAuthority() + return false + } + if (fence.recovery === 'renderer') { + const restoresPublishedInventory = + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1 + this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(true) + for (const leaf of this.leaves.values()) { + this.adoptPreAllocatedHandle(leaf) + } + this.reconcilePtyIncarnationHandles() + this.refreshWritableFlags() + if (restoresPublishedInventory) { + this.markSessionTabsInventoryPublished() + } + return true + } + this.graphReloadLifecycle.begin(windowId) + return false + } + + markGraphReady(windowId: number): void { + if (windowId !== this.authoritativeWindowId) { + return + } + this.graphReloadLifecycle.settleActive('success') + if (windowId !== HEADLESS_RUNTIME_WINDOW_ID) { + this.headlessGraphFallbackAvailable = false + this.pendingHeadlessPromotionWindowId = null + } + this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) + this.refreshWritableFlags() + } + + markGraphReloadFailed( + windowId: number, + _reason: 'renderer-frame-unavailable' | 'renderer-process-gone' + ): void { + if (windowId !== this.authoritativeWindowId) { + return + } + if (this.graphStatus === 'ready') { + this.beginGraphReload(windowId) + } + this.graphReloadLifecycle.settleActive('failure') + this.transitionGraphReloadToTerminalState(windowId) + } + + markGraphUnavailable(windowId: number): void { + if ( + this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && + windowId === this.pendingHeadlessPromotionWindowId + ) { + this.pendingHeadlessPromotionWindowId = null + return + } + if (windowId !== this.authoritativeWindowId) { + return + } + this.graphReloadLifecycle.settleActive('cancelled') + if (this.shouldRestoreHeadlessGraph(windowId)) { + this.pendingHeadlessPromotionWindowId = null + this.restoreHeadlessGraphAuthority() + return + } + // Why: once the authoritative renderer graph disappears, fail closed for live-terminal ops instead of guessing from old state. + if (this.graphStatus !== 'unavailable') { + this.rendererGraphEpoch += 1 + } + this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) + this.authoritativeWindowId = null + this.rememberDetachedPreAllocatedLeaves() + this.tabs.clear() + this.leaves.clear() + this.leavesByPtyId.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.clearPtyIncarnationHandles() + // Why: pre-allocated CLI handles must survive graph unavailability so they can be re-adopted on reconnect. + this.rejectAllWaiters('terminal_handle_stale') + } + + private handleGraphReloadTimeout(windowId: number): void { + if (windowId !== this.authoritativeWindowId || this.graphStatus !== 'reloading') { + return + } + this.transitionGraphReloadToTerminalState(windowId) + } + + private transitionGraphReloadToTerminalState(windowId: number): void { + if (this.shouldRestoreHeadlessGraph(windowId)) { + this.restoreHeadlessGraphAuthority() + return + } + this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) + this.rememberDetachedPreAllocatedLeaves() + this.tabs.clear() + this.leaves.clear() + this.leavesByPtyId.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.clearPtyIncarnationHandles() + this.rejectAllWaiters('terminal_handle_stale') + this.refreshWritableFlags() + } + + private shouldRestoreHeadlessGraph(windowId: number): boolean { + return windowId !== HEADLESS_RUNTIME_WINDOW_ID && this.headlessGraphFallbackAvailable + } + + private restoreHeadlessGraphAuthority(): void { + this.rendererGraphEpoch += 1 + this.authoritativeWindowId = HEADLESS_RUNTIME_WINDOW_ID + this.graphStatus = 'ready' + this.rendererGeneration = null + this.setTerminalSideEffectConsumerAvailable(false) + this.tabs.clear() + this.leaves.clear() + this.leavesByPtyId.clear() + this.handles.clear() + this.handleByLeafKey.clear() + this.clearPtyIncarnationHandles() + this.rejectAllWaiters('terminal_handle_stale') + this.refreshWritableFlags() + this.markSessionTabsInventoryPublished() + } + + private assertGraphReady(): void { + if (this.graphStatus !== 'ready') { + throw new Error('runtime_unavailable') + } + } + + private captureReadyGraphEpoch(): number { + this.assertGraphReady() + return this.rendererGraphEpoch + } + + private assertStableReadyGraph(expectedGraphEpoch: number): void { + if (this.graphStatus !== 'ready' || this.rendererGraphEpoch !== expectedGraphEpoch) { + throw new Error('runtime_unavailable') + } + } + + private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null { + const repos = this.store?.getRepos() ?? [] + const projectGroups = this.store?.getProjectGroups?.() ?? [] + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups, + repos + }) + if (connection.kind === 'ambiguous') { + // Why: a PTY spawns on one runtime target; mixed child-repo connections need an explicit V2 routing decision. + throw new Error('folder_workspace_connection_ambiguous') + } + return connection.kind === 'ssh' ? connection.connectionId : null + } + + private async resolveFolderWorkspaceLaunchScope( + selector: string + ): Promise<(TerminalWorkspaceLaunchScope & { folderWorkspace: FolderWorkspace }) | null> { + const workspace = this.resolveFolderWorkspaceSelector(selector) + if (!workspace) { + return null + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const status = await getFolderWorkspacePathStatus( + this.store, + { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + return { + id: folderWorkspaceKey(workspace.id), + path: workspace.folderPath, + connectionId: this.resolveFolderWorkspaceConnectionId(workspace), + repo: null, + folderWorkspace: workspace + } + } + + private resolveFolderWorkspaceSelector(selector: string): FolderWorkspace | null { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + if (parsed?.type !== 'folder') { + return null + } + const workspace = this.store + ?.getFolderWorkspaces?.() + .find((entry) => entry.id === parsed.folderWorkspaceId) + if (!workspace) { + throw new Error('selector_not_found') + } + return workspace + } + + private async resolveEmulatorWorkspaceId(selector: string): Promise { + const folderWorkspace = this.resolveFolderWorkspaceSelector(selector) + return folderWorkspace + ? folderWorkspaceKey(folderWorkspace.id) + : (await this.resolveWorktreeSelector(selector)).id + } + + private async resolveBrowserWorkspace(selector: string): Promise { + const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) + return folderScope?.folderWorkspace + ? this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace) + : this.resolveWorktreeSelector(selector) + } + + /** + * Closes the window in which snapshots warn that this client's client-hosted pages are still + * unaccounted for. Keyed by paired device because one client attaching says nothing about another. + */ + markClientHostedPagesReconciled(pairedDeviceId: string): void { + this.clientHostedPageReconciliation.markReconciled(pairedDeviceId) + } + + /** + * The execution-host key a client-hosted page in this workspace would be created under now. + * + * Adoption cannot reuse the key an inventory entry reports: native and WSL keys name the runtime + * that minted them, and an SSH key carries a per-process provider epoch, so a restart always + * invalidates them. + * + * The two failure modes are not the same answer. A workspace that no longer resolves is gone and + * its pages have nothing left to be restored into; an execution host that is merely not up yet -- + * an SSH provider mid-reconnect, a project runtime still repairing -- is a "not now", and must + * never be read as permission to retire the page. + */ + async resolveBrowserExecutionHostKeyForWorkspace( + workspaceId: string + ): Promise { + let worktree: ResolvedWorktree + try { + worktree = await this.resolveBrowserWorkspace(`id:${workspaceId}`) + } catch { + return { status: 'workspace-gone' } + } + try { + return { + status: 'resolved', + executionHostKey: browserNetworkExecutionHostKey( + await this.resolveBrowserNetworkExecutionHostForWorktree(worktree) + ) + } + } catch { + return { status: 'unavailable' } + } + } + + private resolveBrowserNetworkExecutionHostForWorktree(worktree?: { + id: string + repoId?: string + hostId?: ExecutionHostId + }): BrowserNetworkExecutionHost | Promise { + const repo = worktree?.repoId ? this.requireStore().getRepo(worktree.repoId) : undefined + const executionHostId = worktree + ? getWorktreeExecutionHostId(worktree, repo) + : LOCAL_EXECUTION_HOST_ID + const parsedHost = parseExecutionHostId(executionHostId) + return resolveRuntimeBrowserNetworkExecutionHost({ + runtimeId: this.getRuntimeId(), + runtimeRevision: this.getStartedAt(), + executionHostId, + ...(worktree + ? { + projectRuntime: resolveLocalProjectRuntimeForWorktreeId( + this.requireStore(), + worktree.id + ) + } + : {}), + ...(parsedHost?.kind === 'ssh' + ? { sshState: getRegisteredSshState(parsedHost.targetId) } + : {}) + }) + } + + private async resolveEmulatorCleanupWorkspaceId(selector: string): Promise { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + return parsed?.type === 'folder' + ? folderWorkspaceKey(parsed.folderWorkspaceId) + : this.resolveEmulatorWorkspaceId(selector) + } + + private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { + const worktree = folderWorkspaceToWorktree(folderWorkspace) + return { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + } + } + + private resolveWorkspaceTerminalStartupCwd( + workspace: Pick, + requestedCwd?: string | null + ): string | undefined { + return resolveTerminalStartupCwd(workspace.path, requestedCwd) + } + + private async resolveTerminalWorkspaceLaunchScope( + selector: string + ): Promise { + return (await this.resolveTerminalWorkspaceLaunchTarget(selector)).scope + } + + private async resolveTerminalWorkspaceLaunchTarget( + selector: string + ): Promise { + const floatingTerminalSelector = + selector === FLOATING_TERMINAL_WORKTREE_ID || + selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` + if (floatingTerminalSelector) { + // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. + return { + scope: { + id: FLOATING_TERMINAL_WORKTREE_ID, + path: homedir(), + connectionId: null, + repo: null, + folderWorkspace: null + }, + managedWorktree: null + } + } + + const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) + if (folderScope) { + return { + scope: folderScope, + managedWorktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace) + } + } + + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) ?? null + return { + scope: { + id: worktree.id, + path: worktree.path, + connectionId: repo?.connectionId ?? null, + repo, + folderWorkspace: null + }, + managedWorktree: worktree + } + } + + private buildTerminalWorkspaceEnv( + scope: TerminalWorkspaceLaunchScope, + baseEnv: Record, + paneKey: string, + tabId: string, + agentTeamsEnv?: Record + ): Record { + const cleanBaseEnv = { ...baseEnv } + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete cleanBaseEnv[key] + } + const env = { + ...cleanBaseEnv, + ...agentTeamsEnv, + ...this.buildAgentHookPtyEnv?.(), + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: scope.id + } + if (!scope.folderWorkspace) { + return env + } + return { + ...env, + ORCA_WORKSPACE_ID: scope.id, + ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId, + ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath + } + } + + private getValidatedExplicitWorktreeIdSelector(selector: string | undefined): string | null { + const worktreeId = getExplicitWorktreeIdSelector(selector) + if ( + worktreeId && + !worktreeId.includes(WORKTREE_ID_SEPARATOR) && + this.store?.getRepo(worktreeId) + ) { + // Why: a registered repo id is a known-invalid worktree id; reject early before fast paths or Git/SSH scans hide the mistake. + throw new WorktreeIdRequiresFullPathError() + } + return worktreeId + } + + /** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */ + private async resolveWorktreeSelector(selector: string): Promise { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) + // Why only `id:`: every other selector kind is matched across the whole fleet, and their + // `selector_ambiguous` contract is defined over all repos. Scoping those would silently pick a + // winner where today they correctly refuse. An `id:` selector already names its repo. + if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { + const scoped = await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) + if (scoped) { + return scoped + } + } + const worktrees = await this.listResolvedWorktrees() + let candidates: ResolvedWorktree[] + + if (selector === 'active') { + throw new Error('selector_not_found') + } + + if (selector.startsWith('identity:')) { + const identityKey = selector.slice('identity:'.length) + candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey) + } else if (selector.startsWith('id:')) { + const worktreeId = explicitWorktreeId ?? selector.slice(3) + candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + if (candidates.length === 0) { + // Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell + // its path differently from the scan — the divergence `path:` has always absorbed. + // The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a + // renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + candidates = comparisonKey + ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) + : candidates + } + if (candidates.length === 0) { + const parsed = splitWorktreeIdForFilesystem(worktreeId) + const repo = parsed ? this.store?.getRepo(parsed.repoId) : null + const fallback = + repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) + ? this.buildResolvedWorktreeFromId(worktreeId) + : null + if (fallback !== null) { + candidates = [fallback] + } + } + } else if (selector.startsWith('path:')) { + // Why exact-spelling-only (#16628): a Linux path names a directory in *some* WSL distro, and + // only the CLI sits in one and can prove which. Guessing here would delete a stranger's. + candidates = worktrees.filter((worktree) => + runtimePathsEqual(worktree.path, selector.slice(5)) + ) + if (candidates.length > 1) { + const hostIds = new Set( + candidates.map((worktree) => { + const repo = this.store?.getRepo(worktree.repoId) + return getWorktreeExecutionHostId(worktree, repo) + }) + ) + // Why: duplicate registrations on one host describe one path; identical paths on different hosts do not. + if (hostIds.size === 1) { + candidates = [candidates[0]] + } + } + } else if (selector.startsWith('branch:')) { + const branchSelector = selector.slice(7) + candidates = worktrees.filter((worktree) => + branchSelectorMatches(worktree.branch, branchSelector) + ) + } else if (selector.startsWith('name:')) { + // Keep display-name matching exact so duplicate names hit the same ambiguity path as other selectors. + candidates = worktrees.filter((worktree) => worktree.displayName === selector.slice(5)) + } else if (selector.startsWith('issue:')) { + candidates = worktrees.filter( + (worktree) => + worktree.linkedIssue !== null && String(worktree.linkedIssue) === selector.slice(6) + ) + } else { + candidates = worktrees.filter( + (worktree) => + worktree.id === selector || + runtimePathsEqual(worktree.path, selector) || + branchSelectorMatches(worktree.branch, selector) + ) + } + + if (candidates.length === 1) { + return candidates[0] + } + if (candidates.length > 1) { + throw new Error('selector_ambiguous') + } + throw new Error('selector_not_found') + } + + private async resolveWorkspaceParentSelector(selector: string): Promise { + const rawSelector = selector.startsWith('id:') ? selector.slice('id:'.length) : selector + const parsed = parseWorkspaceKey(rawSelector) + if (parsed?.type === 'folder') { + const folderWorkspace = this.store + ?.getFolderWorkspaces?.() + .find((workspace) => workspace.id === parsed.folderWorkspaceId) + if (!folderWorkspace) { + throw new Error('selector_not_found') + } + return { + type: 'folder', + workspaceKey: folderWorkspaceKey(folderWorkspace.id), + folderWorkspace, + instanceId: null + } + } + const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + return { + type: 'worktree', + workspaceKey: worktreeWorkspaceKey(worktree.id), + worktree, + instanceId: worktree.instanceId ?? null + } + } + + private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { + const childWorktreeId = child.id + const parentWorktreeId = parent.id + if (childWorktreeId === parentWorktreeId) { + throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.') + } + if (!sharesResolvedWorktreeLineageBoundary(child, parent)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Parent worktree must belong to the same repository, execution host, and project.' + ) + } + const instanceByWorktreeId = new Map( + this.resolvedWorktreeCache?.worktrees.map((worktree) => [ + worktree.id, + worktree.instanceId + ]) ?? [ + [child.id, child.instanceId], + [parent.id, parent.instanceId] + ] + ) + let cursor: string | undefined = parentWorktreeId + const visited = new Set([childWorktreeId]) + while (cursor) { + if (visited.has(cursor)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CYCLE', + 'Parent selector would create a lineage cycle.' + ) + } + visited.add(cursor) + const lineage = this.store?.getWorktreeLineage?.(cursor) + if (!lineage) { + break + } + const cursorInstanceId = instanceByWorktreeId.get(cursor) + const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) + if ( + cursorInstanceId !== lineage.worktreeInstanceId || + parentInstanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + cursor = lineage.parentWorktreeId + } + } + + private async resolveLineageForWorktreeCreate( + input?: WorktreeLineageInput + ): Promise { + const parentSelectorNextSteps = [ + 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', + 'Retry with --no-parent to create without lineage.' + ] + const parentSelectorNotFoundMessage = (err: unknown): string => + err instanceof WorktreeIdRequiresFullPathError + ? err.message + : 'Parent selector was not found.' + + if (!input) { + return { kind: 'none', warnings: [] } + } + + if (input.noParent === true && (input.parentWorkspace || input.parentWorktree)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Choose either one parent selector or --no-parent.' + ) + } + if (input.parentWorkspace && input.parentWorktree) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Choose either one parent selector or --no-parent.' + ) + } + + if (input.noParent === true) { + return { kind: 'none', warnings: [] } + } + + if (input.parentWorkspace) { + try { + const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace) + // Why: a picker in the app must record the same provenance as a local create, or the same + // user action would carry different cleanup semantics depending on where the repo lives. + return { + kind: 'lineage', + parent, + origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli', + capture: + input.parentWorkspaceOrigin === 'manual' + ? { + source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace', + confidence: 'explicit' + } + : { source: 'explicit-cli-flag', confidence: 'explicit' } + } + } catch (err) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_NOT_FOUND', + parentSelectorNotFoundMessage(err), + { + nextSteps: parentSelectorNextSteps + } + ) + } + } + + if (input.parentWorktree) { + try { + const parent = await this.resolveWorktreeSelector(input.parentWorktree) + return { + kind: 'lineage', + parent: { + type: 'worktree', + workspaceKey: worktreeWorkspaceKey(parent.id), + worktree: parent, + instanceId: parent.instanceId ?? null + }, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + } catch (err) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_NOT_FOUND', + parentSelectorNotFoundMessage(err), + { + nextSteps: parentSelectorNextSteps + } + ) + } + } + + const warnings: WorktreeLineageWarning[] = [] + const candidates: WorktreeLineageCandidate[] = [] + let cwdCandidate: WorktreeLineageCandidate | null = null + let terminalContextResolved = false + + if (input.envParentWorkspace) { + try { + candidates.push({ + source: 'env-workspace', + parent: await this.resolveWorkspaceParentSelector(input.envParentWorkspace) + }) + } catch { + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: 'Worktree created, but Orca could not validate the environment parent context.', + details: { envParentWorkspace: input.envParentWorkspace } + }) + } + } + + if (input.orchestrationContext?.parentWorktreeId) { + try { + const parent = await this.resolveWorktreeSelector( + `id:${input.orchestrationContext.parentWorktreeId}` + ) + candidates.push({ + source: 'orchestration-context', + parent: { + type: 'worktree', + workspaceKey: worktreeWorkspaceKey(parent.id), + worktree: parent, + instanceId: parent.instanceId ?? null + } + }) + } catch { + // Keep creation recoverable; the warning below covers missing inferred context. + } + } + + const commentTaskId = extractOrchestrationTaskId(input.comment) + if (commentTaskId) { + const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) + if (candidate) { + candidates.push(candidate) + } + } + + if (input.callerTerminalHandle) { + try { + const terminal = await this.showTerminal(input.callerTerminalHandle) + const terminalParent = await this.resolveWorkspaceParentSelector( + `id:${terminal.worktreeId}` + ) + const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( + input.callerTerminalHandle + ) + const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() + if (activeDispatch) { + candidates.push({ + source: 'orchestration-context', + parent: terminalParent, + taskId: activeDispatch.task_id, + ...(activeRun + ? { + orchestrationRunId: activeRun.id, + coordinatorHandle: activeRun.coordinator_handle + } + : {}) + }) + } else { + candidates.push({ + source: 'terminal-context', + parent: terminalParent + }) + } + terminalContextResolved = true + } catch { + // Why: a stale terminal handle (reload/SSH reconnect) shouldn't drop lineage; keep resolving other inferred candidates. + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the caller terminal as a parent context.', + details: { callerTerminalHandle: input.callerTerminalHandle } + }) + } + } + + if (input.cwdParentWorktree) { + try { + cwdCandidate = { + source: 'cwd-context', + parent: await this.resolveWorkspaceParentSelector(input.cwdParentWorktree) + } + } catch { + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the current directory as a parent context.', + details: { cwdParentWorktree: input.cwdParentWorktree } + }) + } + } + + if (candidates.length === 0 && cwdCandidate) { + candidates.push(cwdCandidate) + } + + if (candidates.length === 0) { + return { kind: 'none', warnings } + } + + const [first] = candidates + const conflict = candidates.find( + (candidate) => candidate.parent.workspaceKey !== first.parent.workspaceKey + ) + if (conflict) { + return { + kind: 'none', + warnings: [ + { + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: 'Worktree created, but Orca could not prove which parent context caused it.', + details: { + terminalParentWorkspaceKey: candidates.find((c) => c.source === 'terminal-context') + ?.parent.workspaceKey, + envParentWorkspaceKey: candidates.find((c) => c.source === 'env-workspace')?.parent + .workspaceKey, + orchestrationParentWorkspaceKey: candidates.find( + (c) => c.source === 'orchestration-context' + )?.parent.workspaceKey + } + } + ] + } + } + + const preferred = + candidates.find((candidate) => candidate.source === 'env-workspace') ?? + candidates.find((candidate) => candidate.source === 'orchestration-context') ?? + first + return { + kind: 'lineage', + parent: preferred.parent, + origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', + capture: { source: preferred.source, confidence: 'inferred' }, + ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) + ? { + orchestrationRunId: + preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId + } + : {}), + ...((preferred.taskId ?? input.orchestrationContext?.taskId) + ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } + : {}), + ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) + ? { + coordinatorHandle: + preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle + } + : {}), + ...(terminalContextResolved && input.callerTerminalHandle + ? { createdByTerminalHandle: input.callerTerminalHandle } + : {}) + } + } + + private async resolveLineageCandidateForTaskId( + taskId: string + ): Promise { + const db = this.getOrchestrationDbIfAvailable() + const dispatch = db?.getDispatchContext(taskId) + // Why: agent-created tasks may never be dispatched, but the creating terminal still identifies the parent workspace. + const parentHandle = + dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle + if (!parentHandle) { + return null + } + try { + const terminal = await this.showTerminal(parentHandle) + const parent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + return { + source: 'orchestration-context', + parent: { + type: 'worktree', + workspaceKey: worktreeWorkspaceKey(parent.id), + worktree: parent, + instanceId: parent.instanceId ?? null + }, + taskId + } + } catch { + return null + } + } + + private getOrchestrationDbIfAvailable(): OrchestrationDb | null { + try { + return this._orchestrationDb ?? this.getOrchestrationDb() + } catch { + return this._orchestrationDb + } + } + + async hydrateInferredWorktreeLineage(): Promise { + const store = this.store + if ( + !store || + typeof store.getWorktreeLineage !== 'function' || + typeof store.setWorktreeLineage !== 'function' + ) { + return + } + + const worktrees = await this.listResolvedWorktrees() + for (const worktree of worktrees) { + if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { + continue + } + const taskId = extractOrchestrationTaskId(worktree.comment) + if (!taskId) { + continue + } + const candidate = await this.resolveLineageCandidateForTaskId(taskId) + if ( + !candidate?.parent.instanceId || + candidate.parent.type !== 'worktree' || + candidate.parent.worktree.id === worktree.id + ) { + continue + } + try { + this.validateLineageParent(worktree, candidate.parent.worktree) + } catch { + continue + } + store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: candidate.parent.worktree.id, + parentWorktreeInstanceId: candidate.parent.instanceId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId, + createdAt: Date.now() + }) + } + } + + async listWorktreeLineage(): Promise> { + await this.hydrateInferredWorktreeLineage() + return this.store?.getAllWorktreeLineage?.() ?? {} + } + + async listWorkspaceLineage(): Promise> { + await this.hydrateInferredWorktreeLineage() + return this.store?.getAllWorkspaceLineage?.() ?? {} + } + + // Why: one selector grammar, so connection-scoped resolution can narrow the same + // candidate set instead of reimplementing (and diverging from) the matching rules. + private selectReposBySelector(selector: string): Repo[] { + const repos = this.store?.getRepos() ?? [] + if (selector.startsWith('id:')) { + return repos.filter((repo) => repo.id === selector.slice(3)) + } + if (selector.startsWith('path:')) { + return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) + } + if (selector.startsWith('name:')) { + return repos.filter((repo) => repo.displayName === selector.slice(5)) + } + return repos.filter( + (repo) => + repo.id === selector || + runtimePathsEqual(repo.path, selector) || + repo.displayName === selector + ) + } + + private async resolveRepoSelector(selector: string): Promise { + if (!this.store) { + throw new Error('repo_not_found') + } + const candidates = this.selectReposBySelector(selector) + + if (candidates.length === 1) { + return candidates[0] + } + if (candidates.length > 1) { + throw new Error('selector_ambiguous') + } + throw new Error('repo_not_found') + } + + private requireStore(): Store { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return this.store as unknown as Store + } + + private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null { + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed?.repoId || !parsed.worktreePath) { + return null + } + const repo = this.store?.getRepos?.()?.find((entry) => entry.id === parsed.repoId) + const git = { + path: parsed.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false + } + const meta = this.store?.getWorktreeMeta(worktreeId) + const merged = { + ...mergeWorktree(parsed.repoId, git, meta, repo?.displayName), + ...(repo ? { hostId: meta?.hostId ?? getRepoExecutionHostId(repo) } : {}) + } + return { + ...merged, + id: worktreeId, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git, + displayName: merged.displayName, + comment: merged.comment + } + } + + private listKnownResolvedWorktreesForExplicitTarget( + targetWorktreeId: string, + targetWorktree: ResolvedWorktree | null + ): ResolvedWorktree[] { + if (!this.store || !targetWorktree) { + return [] + } + const target = splitWorktreeIdForFilesystem(targetWorktreeId) + if (!target?.repoId || !target.worktreePath) { + // Folder workspace keys have no repo/path tuple, but the converted row + // is already authoritative for this explicit target. + return [targetWorktree] + } + const worktreeIds = new Set( + Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { + const parsed = splitWorktreeIdForFilesystem(worktreeId) + return ( + parsed?.repoId === target.repoId && + Boolean(parsed.worktreePath) && + (isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) || + isPathInsideOrEqual(parsed.worktreePath, target.worktreePath)) + ) + }) + ) + worktreeIds.add(targetWorktreeId) + + const resolved: ResolvedWorktree[] = [] + for (const worktreeId of worktreeIds) { + const worktree = + worktreeId === targetWorktreeId + ? targetWorktree + : this.buildResolvedWorktreeFromId(worktreeId) + if (worktree) { + resolved.push(worktree) + } + } + return resolved + } + + /** A warm fleet snapshot already answers any selector for free, so scoped scanning must yield to it. */ + private hasFreshResolvedWorktreeCache(): boolean { + return Boolean(this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now()) + } + + private async listResolvedWorktrees(): Promise { + return (await this.listResolvedWorktreeSnapshot()).worktrees + } + + private async listResolvedWorktreeSnapshot(): Promise { + if (!this.store) { + return { worktrees: [], platformByRepoId: new Map() } + } + const now = Date.now() + if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { + return this.resolvedWorktreeCache + } + const generation = this.resolvedWorktreeGeneration + if (this.resolvedWorktreeInFlight?.generation === generation) { + return this.resolvedWorktreeInFlight.promise + } + + const promise = this.computeResolvedWorktrees(generation) + this.resolvedWorktreeInFlight = { generation, promise } + try { + return await promise + } finally { + if (this.resolvedWorktreeInFlight?.promise === promise) { + this.resolvedWorktreeInFlight = null + } + } + } + + private async computeResolvedWorktrees(generation: number): Promise { + if (!this.store) { + return { worktrees: [], platformByRepoId: new Map() } + } + const metaById = this.store.getAllWorktreeMeta() ?? {} + const repos = this.store.getRepos() + const repoOwnerCounts = new Map() + for (const repo of repos) { + repoOwnerCounts.set(repo.id, (repoOwnerCounts.get(repo.id) ?? 0) + 1) + } + const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) + const platformByRepoId = new Map( + repos.map((repo) => [ + repo.id, + getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) + ]) + ) + const deps = this.repoWorktreeRowDeps() + const perRepoWorktrees = await Promise.all( + repos.map( + async (repo) => + await resolveRepoWorktreeRows( + deps, + repo, + metaById, + projectRuntimeByRepoId, + repoOwnerCounts.get(repo.id) ?? 1 + ) + ) + ) + const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} + const worktrees = perRepoWorktrees.flatMap((rows) => + projectResolvedWorktreeLineage(rows, lineageById) + ) + // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. + // Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an + // already-expired entry, so the very next poll recomputes and every caller repeats the same slow path. + if (generation === this.resolvedWorktreeGeneration) { + this.resolvedWorktreeCache = { + worktrees, + platformByRepoId, + expiresAt: Date.now() + RESOLVED_WORKTREE_CACHE_TTL_MS + } + } + return { worktrees, platformByRepoId } + } + + /** Bind the runtime-owned scan cache and folder-workspace stamping into the row resolver. */ + private repoWorktreeRowDeps(): RepoWorktreeRowDeps { + const store = this.requireStore() + return { + store, + scanRepo: (repo, projectRuntimeByRepoId) => + this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), + listFolderWorkspaces: (repo, repoOwnerCount) => + listRuntimeFolderWorkspaces(store, repo, repoOwnerCount) + } + } + + private async resolveExplicitWorktreeIdScoped( + worktreeId: string, + requiredHostId?: ExecutionHostId + ): Promise { + if (!this.store) { + return null + } + return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId) + } + + private async listRepoWorktreesForResolution( + repo: Repo, + projectRuntimeByRepoId?: ReadonlyMap + ): Promise { + const now = Date.now() + const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` + const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0 + const projectRuntime = repo.connectionId + ? undefined + : projectRuntimeByRepoId + ? projectRuntimeByRepoId.get(repo.id) + : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) + const runtimeKey = projectRuntime + ? projectRuntime.status === 'resolved' + ? projectRuntime.runtime.cacheKey + : projectRuntime.repair.cacheKey + : repo.connectionId + ? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}` + : 'local:default' + const cached = this.worktreeScanCache.get(scanScopeKey) + if ( + cached?.generation === generation && + cached.runtimeKey === runtimeKey && + cached.expiresAt > now + ) { + return cached.result + } + const inFlight = this.worktreeScanInFlight.get(scanScopeKey) + if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { + const refresh = await inFlight.promise + if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { + return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) + } + return refresh.result + } + const reusableCached = + cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null + const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) + this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise }) + try { + const refresh = await promise + // Why: fence the caller as well as cache writeback, or an event refresh can consume a stale scan. + if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { + return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) + } + // Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll. + if ( + (refresh.result.ok || !repo.connectionId) && + this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise + ) { + const entry: RuntimeWorktreeScanCache = { + generation, + runtimeKey, + result: refresh.result, + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), + adminFingerprint: refresh.adminFingerprint, + scannedAt: refresh.scannedAt + } + this.worktreeScanCache.set(scanScopeKey, entry) + // Why a writeback instead of storing the promise: a probe that never settles must not be + // awaited by a later refresh. Identity check keeps a stale probe out of a newer entry. + void refresh.adminFingerprintProbe?.then((fingerprint) => { + if (this.worktreeScanCache.get(scanScopeKey) === entry) { + entry.adminFingerprint = fingerprint + } + }) + } + return refresh.result + } finally { + if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) { + this.worktreeScanInFlight.delete(scanScopeKey) + } + } + } + + /** + * Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap + * Git-admin fingerprint proves nothing changed since the cached scan. + */ + private async refreshRepoWorktreeScan( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + cached: RuntimeWorktreeScanCache | null + ): Promise { + const scannedAt = Date.now() + // SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them. + const fingerprintCapable = + !repo.connectionId && + // Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a + // fingerprint, so reading one would be pure work. Agent-scratch roots are that case today. + resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS && + !getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro + // Why issue it before the scan: a change landing while the scan runs must not be stamped as + // already-observed, or the next probe would mask it until the reconciliation deadline. + const probe = fingerprintCapable ? this.startRepoWorktreeAdminFingerprintProbe(repo) : null + const reusable = + cached?.result.ok === true && + scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + ? cached + : null + if (probe && reusable) { + // Why await only here: this is the one branch whose decision needs the probe. A scan-bound + // caller must never wait on it, or every cold read pays filesystem latency it cannot use. + const probed = await withTimeoutResult(probe, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) + if (!probed.ok) { + // Why log: expiry and "fingerprint unavailable" both surface as `null`, so a wedged mount is + // otherwise indistinguishable from a repo that simply cannot be fingerprinted. + console.warn('[worktree-scan] admin fingerprint probe expired; running a full scan', { + repoId: repo.id, + timeoutMs: WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS + }) + } + const current = probed.ok ? probed.value : null + if (current !== null && current === reusable.adminFingerprint) { + return { + result: reusable.result, + adminFingerprint: current, + adminFingerprintProbe: null, + scannedAt: reusable.scannedAt + } + } + } + const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + return { result, adminFingerprint: null, adminFingerprintProbe: probe, scannedAt } + } + + /** + * Read one repo's Git-admin fingerprint, unless that repo's previous read is still outstanding. + * Why the gate: `withTimeout` abandons a probe without cancelling it, and readdir/stat take no + * AbortSignal — on a wedged mount a fresh probe per refresh would pin every libuv fs thread. + */ + private startRepoWorktreeAdminFingerprintProbe(repo: Repo): Promise | null { + if (this.worktreeAdminFingerprintProbes.has(repo.id)) { + return null + } + this.worktreeAdminFingerprintProbes.add(repo.id) + return readRepoWorktreeAdminFingerprint(repo.path) + .catch(() => null) + .finally(() => { + this.worktreeAdminFingerprintProbes.delete(repo.id) + }) + } + + private async listRepoWorktreesForResolutionUncached( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined + ): Promise { + if (!repo.connectionId) { + return await scanLocalRepoWorktreesForResolution( + repo.path, + getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) + ) + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } + } + try { + return { ok: true, worktrees: await provider.listWorktrees(repo.path) } + } catch { + return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } + } + } + + private listStoredWorktreesForResolution(repo: Repo): GitWorktreeInfo[] { + return this.store ? listStoredWorktreeRowsForRepo(this.requireStore(), repo) : [] + } + + private async getResolvedWorktreeMap(): Promise> { + return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) + } + + private invalidateResolvedWorktreeCache(): void { + this.resolvedWorktreeGeneration += 1 + this.resolvedWorktreeCache = null + } + + private invalidateWorktreeScanCacheForRepo(repoId: string): void { + const prefix = `${repoId}\0` + const scopeKeys = new Set( + this.store + ?.getRepos() + .filter((repo) => repo.id === repoId) + .map((repo) => `${repoId}\0${getRepoExecutionHostId(repo)}`) ?? [] + ) + for (const keys of [ + this.worktreeScanGenerations.keys(), + this.worktreeScanCache.keys(), + this.worktreeScanInFlight.keys() + ]) { + for (const key of keys) { + if (key.startsWith(prefix)) { + scopeKeys.add(key) + } + } + } + for (const key of scopeKeys) { + this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) + this.worktreeScanCache.delete(key) + this.worktreeScanInFlight.delete(key) + } + } + + private invalidateSshWorktreeScanCacheInternal(targetId: string): void { + const repos = this.store?.getRepos() ?? [] + const affectedRepos = repos.filter((repo) => repo.connectionId === targetId) + const affectedScopeKeys = new Set( + affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`) + ) + for (const key of affectedScopeKeys) { + this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) + this.worktreeScanCache.delete(key) + this.worktreeScanInFlight.delete(key) + } + if (affectedScopeKeys.size > 0) { + this.resolvedWorktreeGeneration += 1 + this.resolvedWorktreeCache = null + } + } + + /** Invalidate the worktree cache and tell the renderer to re-list after an out-of-band branch change so the new name surfaces immediately. */ + notifyBranchRenamed(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + this.notifyWorktreesChanged(repoId) + } + + /** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */ + notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void { + this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + this.invalidatePtyLivenessSnapshot() + this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId }) + // Mirror notifyBranchRenamed so in-process onClientEvent listeners also see the rename. + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + + notifyFolderWorkspaceChanged(): void { + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + } + + private recordPtyWorktree( + ptyId: string, + worktreeId: string, + state: Partial< + Pick< + RuntimePtyWorktreeRecord, + | 'connected' + | 'lastOutputAt' + | 'preview' + | 'tabId' + | 'paneKey' + | 'title' + | 'connectionId' + | 'runtimeSessionOwned' + | 'isWsl' + | 'wslDistro' + | 'incarnationId' + | 'agentSessionOwners' + > + > = {} + ): RuntimePtyWorktreeRecord { + this.invalidatePtyLivenessSnapshot() + let pty = this.ptysById.get(ptyId) + if (!pty) { + const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null + const connectionId = state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + const fallbackWslDistro = + process.platform === 'win32' && connectionId === null && worktreePath + ? parseWslUncPath(worktreePath)?.distro + : undefined + const wslDistro = + connectionId === null + ? (state.wslDistro ?? this.wslDistroByPtyId.get(ptyId) ?? fallbackWslDistro ?? null) + : null + pty = { + ptyId, + incarnationId: state.incarnationId ?? null, + worktreeId, + connectionId, + runtimeSessionOwned: state.runtimeSessionOwned ?? false, + isWsl: state.isWsl ?? null, + wslDistro, + tabId: state.tabId ?? null, + paneKey: state.paneKey ?? null, + launchConfig: null, + launchToken: null, + launchIncarnationId: null, + launchAgent: null, + agentSessionOwners: (state.agentSessionOwners ?? []).map(cloneAgentSessionOwnerBinding), + foregroundAgent: null, + connected: state.connected ?? true, + disconnectedAt: state.connected === false ? Date.now() : null, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastAgentStatusObservedLive: false, + lastAgentStatusStartedAtEpochMs: null, + lastAgentStatusRichInvalidatedAtEpochMs: null, + lastOscTitle: null, + lastOscTitleAt: null, + lastOscTitleEpochMs: null, + managementTitle: null, + managementTitleAt: null, + controllerTitle: null, + title: state.title ?? null, + titleUpdatedAt: titleObservedAt, + lastOutputAt: state.lastOutputAt ?? null, + tailBuffer: [], + tailTranscriptBuffer: [], + tailTranscriptChars: 0, + tailPartialLine: '', + tailPendingAnsi: '', + tailRedrawCursor: null, + tailTruncated: false, + tailLinesTotal: 0, + preview: state.preview ?? '', + waitBlockedAt: null + } + if (state.title) { + this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) + } + this.ptysById.set(ptyId, pty) + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else if (connectionId !== null) { + // Why: restored SSH IDs can collide with stale local parser state; connection ownership must win before their first output is parsed. + this.wslDistroByPtyId.delete(ptyId) + } + // Why: restored/controller-discovered PTYs learn their worktree here without registerPty(), so URL enrichment must bind at this source. + advertisedUrlWatcher.bindPty(ptyId, worktreeId) + return pty + } + + pty.worktreeId = worktreeId + if ( + state.incarnationId !== undefined && + pty.incarnationId !== null && + state.incarnationId !== pty.incarnationId + ) { + pty.agentSessionOwners = [] + } + if (state.incarnationId !== undefined) { + if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) { + this.invalidatePtyIncarnationHandle(ptyId) + } + pty.incarnationId = state.incarnationId + } + if (state.agentSessionOwners !== undefined) { + pty.agentSessionOwners = state.agentSessionOwners.map(cloneAgentSessionOwnerBinding) + } + if (state.connectionId !== undefined) { + pty.connectionId = state.connectionId + if (state.connectionId !== null) { + pty.wslDistro = null + this.wslDistroByPtyId.delete(ptyId) + } + } + if (state.runtimeSessionOwned !== undefined) { + pty.runtimeSessionOwned = state.runtimeSessionOwned + } + if (state.isWsl !== undefined) { + pty.isWsl = state.isWsl + } + if (state.wslDistro !== undefined) { + pty.wslDistro = state.wslDistro + if (state.wslDistro) { + this.wslDistroByPtyId.set(ptyId, state.wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + } + if (state.tabId !== undefined) { + pty.tabId = state.tabId + } + if (state.paneKey !== undefined) { + pty.paneKey = state.paneKey + } + if (state.connected !== undefined) { + pty.connected = state.connected + pty.disconnectedAt = state.connected ? null : (pty.disconnectedAt ?? Date.now()) + } + if (state.lastOutputAt !== undefined) { + pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) + } + if (state.preview !== undefined && state.preview.length > 0) { + pty.preview = state.preview + } + if (state.title !== undefined && state.title !== null && state.title.length > 0) { + const observedAt = this.nextTitleObservationSequence() + pty.title = state.title + pty.titleUpdatedAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, state.title, observedAt) + } + // Why: recordPtyWorktree is the common lifecycle point for every path that resolves a PTY's worktree (renderer restore, controller list). + advertisedUrlWatcher.bindPty(ptyId, worktreeId) + return pty + } + + private makeRuntimePaneKey( + leaf: Pick + ): string { + return isTerminalLeafId(leaf.leafId) + ? makePaneKey(leaf.tabId, leaf.leafId) + : `${leaf.tabId}:${leaf.paneRuntimeId}` + } + + private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { + const existing = this.ptysById.get(ptyId) + if (existing) { + return existing + } + const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) + if (!inferredWorktreeId) { + return null + } + // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so mobile summaries survive renderer graph gaps and reloads. + return this.recordPtyWorktree(ptyId, inferredWorktreeId) + } + + /** Synchronizes PTY tracking records with running daemon sessions, querying their foreground agent states. */ + private async refreshPtyWorktreeRecordsFromController( + resolvedWorktrees: ResolvedWorktree[], + targetWorktreeId: string | null = null, + deadline?: number, + signal?: AbortSignal + ): Promise | null> { + this.ptyLivenessRefreshInProgress += 1 + try { + const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId, + deadline, + undefined, + false, + signal + ) + if (inventory) { + this.ptyLivenessRefreshRequired = false + } + return inventory ? new Set(inventory.livePtyIds) : null + } finally { + this.ptyLivenessRefreshInProgress -= 1 + } + } + + private async refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees: ResolvedWorktree[], + targetWorktreeId: string | null = null, + deadline?: number, + connectionId?: string | null, + retryStale = false, + signal?: AbortSignal + ): Promise { + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() + if (targetedLiveness !== null) { + return { + livePtyIds: targetedLiveness, + allLivePtyIds: targetedLiveness, + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) + } + } + } + if (!this.ptyController?.listProcesses) { + return null + } + const inventoryGeneration = this.ptyControllerInventorySequence + 1 + this.ptyControllerInventorySequence = inventoryGeneration + const livenessObservationAtStart = this.ptyLivenessObservationSequence + const providerKey = typeof connectionId === 'string' ? `ssh:${connectionId}` : 'local' + if (connectionId === undefined) { + this.ptyControllerAggregateInventoryGeneration = inventoryGeneration + } else { + this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) + } + const listBudgetMs = + deadline === undefined + ? PTY_CONTROLLER_LIST_TIMEOUT_MS + : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) + // Why: give each provider a deadline strictly inside our own, so a relay that + // never answers still leaves the aggregate time to return the providers that did + // — expiring at the same instant would discard the whole inventory instead. + const providerListOpts = { + deadlineMs: Date.now() + Math.max(1, listBudgetMs - PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS), + ...(signal ? { signal } : {}) + } + const processInventory = + connectionId === undefined && this.ptyController.listProcessesWithHostScope + ? this.ptyController.listProcessesWithHostScope(providerListOpts) + : this.ptyController.listProcesses(connectionId, providerListOpts).then((processes) => { + const hostIds = new Set() + if (connectionId === undefined || connectionId === null) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } else { + hostIds.add(toSshExecutionHostId(connectionId)) + } + if (connectionId === undefined) { + for (const process of processes) { + const hostId = getPtyExecutionHost(process.id) + if ( + hostId && + hostId !== 'foreign' && + parseExecutionHostId(hostId)?.kind === 'ssh' + ) { + hostIds.add(hostId) + } + } + } + return { processes, hostIds: [...hostIds] } + }) + const sessionsResult = await withTimeoutResult(processInventory, listBudgetMs) + if (!sessionsResult.ok) { + // Why: a transient controller failure is not evidence that retained PTYs exited. + return null + } + const isCurrentInventory = + connectionId === undefined + ? this.ptyControllerAggregateInventoryGeneration === inventoryGeneration && + ![...this.ptyControllerInventoryGenerationByProvider.values()].some( + (generation) => generation > inventoryGeneration + ) + : this.ptyControllerInventoryGenerationByProvider.get(providerKey) === + inventoryGeneration && + this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration + if (!isCurrentInventory) { + // A fleet census that began after this targeted poll must not turn a + // user-driven open into an empty result. Re-query the owning provider; + // the second generation is then fenced against both operations. + if (targetWorktreeId !== null && !retryStale) { + return this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId, + deadline, + connectionId, + true, + signal + ) + } + return null + } + const sessions = sessionsResult.value.processes + const queriedHostIds = new Set(sessionsResult.value.hostIds) + const controllerIdentityByPtyId = new Map() + const ptyIdByControllerHandle = new Map() + const ambiguousControllerPtyIds = new Set() + for (const session of sessions) { + const handle = session.terminalHandle?.trim() + const incarnationId = session.incarnationId?.trim() + if (!handle?.startsWith('term_') || !incarnationId) { + continue + } + const priorPtyId = ptyIdByControllerHandle.get(handle) + if (priorPtyId && priorPtyId !== session.id) { + ambiguousControllerPtyIds.add(priorPtyId) + ambiguousControllerPtyIds.add(session.id) + controllerIdentityByPtyId.delete(priorPtyId) + continue + } + if (controllerIdentityByPtyId.has(session.id)) { + ambiguousControllerPtyIds.add(session.id) + controllerIdentityByPtyId.delete(session.id) + continue + } + ptyIdByControllerHandle.set(handle, session.id) + controllerIdentityByPtyId.set(session.id, { + handle, + incarnationId, + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}) + }) + } + for (const ptyId of ambiguousControllerPtyIds) { + controllerIdentityByPtyId.delete(ptyId) + } + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) + const persistedIndexesByHostId = new Map< + ExecutionHostId, + { + worktreeIdByPtyId: ReadonlyMap + surfaceByPtyId: ReturnType + } + >() + const getPersistedIndexes = (hostId: ExecutionHostId) => { + const existing = persistedIndexesByHostId.get(hostId) + if (existing) { + return existing + } + const persistedSession = this.store?.getWorkspaceSession?.(hostId) + const indexes = { + worktreeIdByPtyId: indexPersistedPtyWorktreeBindings(persistedSession), + surfaceByPtyId: indexPersistedPtySurfaceBindings(persistedSession) + } + persistedIndexesByHostId.set(hostId, indexes) + return indexes + } + const allLivePtyIds = new Set(sessions.map((session) => session.id)) + const selectedLivePtyIds = new Set() + for (const session of sessions) { + // The owning inventory positively observed this PTY again; prior lost-contact doubt is stale. + this.forgetPtyLivenessVerdict(session.id, livenessObservationAtStart) + const sessionConnectionId = + parseAppSshPtyId(session.id)?.connectionId ?? + (typeof connectionId === 'string' ? connectionId : null) + const persistedIndexes = getPersistedIndexes( + sessionConnectionId ? toSshExecutionHostId(sessionConnectionId) : LOCAL_EXECUTION_HOST_ID + ) + const controllerIdentity = controllerIdentityByPtyId.get(session.id) + const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) + const providerWorktree = session.worktreeId + ? findResolvedWorktree(session.worktreeId) + : undefined + const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) + const persistedWorktree = persistedWorktreeId + ? findResolvedWorktree(persistedWorktreeId) + : undefined + const hasMigrationEvidence = + Boolean(session.worktreeId) && + !providerWorktree && + Boolean(persistedWorktree) && + Boolean(inferredWorktreeId) && + worktreeIdsEqual(session.worktreeId as string, inferredWorktreeId as string) + // Why: an unresolved explicit provider owner remains authoritative unless the session id proves it was frozen before a persisted rename migration. + const worktreeId = providerWorktree + ? providerWorktree.id + : hasMigrationEvidence + ? (persistedWorktree?.id ?? null) + : (session.worktreeId ?? + persistedWorktree?.id ?? + inferredWorktreeId ?? + findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId)) + const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id) + const restoresExactSurface = + persistedSurface && + session.incarnationId && + persistedSurface.incarnationId === session.incarnationId && + Boolean(worktreeId) && + worktreeIdsEqual(persistedSurface.worktreeId, worktreeId as string) + this.adoptControllerTerminalHandle( + session.id, + controllerIdentity?.handle ?? session.terminalHandle, + controllerIdentity?.incarnationId ?? session.incarnationId, + { exactRestoredSurface: Boolean(restoresExactSurface && controllerIdentity) } + ) + if (!targetWorktreeId || (worktreeId && worktreeIdsEqual(worktreeId, targetWorktreeId))) { + selectedLivePtyIds.add(session.id) + } + if (targetWorktreeId && (!worktreeId || !worktreeIdsEqual(worktreeId, targetWorktreeId))) { + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(session.id) + if (receipt && worktreeIdsEqual(receipt.worktreeId, targetWorktreeId)) { + this.restoredOrchestrationAuthorityByPtyId.delete(session.id) + } + continue + } + this.restoredOrchestrationAuthorityByPtyId.delete(session.id) + if (worktreeId) { + const pty = this.recordPtyWorktree(session.id, worktreeId, { + connected: true, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [], + ...(session.wslDistro !== undefined + ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } + : {}), + ...(restoresExactSurface + ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } + : {}) + }) + if (restoresExactSurface && controllerIdentity) { + this.rememberRestoredOrchestrationAuthority( + pty, + controllerIdentity.handle, + controllerIdentity.incarnationId + ) + } else { + this.restoredOrchestrationAuthorityByPtyId.delete(session.id) + } + pty.controllerTitle = session.title?.trim() || null + this.reconcileSubscriberDrivenProviderAttach(session.id) + } + // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. + this.refreshPtyForegroundAgent(session.id) + } + for (const pty of this.ptysById.values()) { + if (connectionId !== undefined && pty.connectionId !== connectionId) { + continue + } + if (!allLivePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { + const currentVerdict = this.ptyLivenessVerdictByPtyId.get(pty.ptyId) + if ( + currentVerdict && + currentVerdict.observedAt > livenessObservationAtStart && + currentVerdict.verdict.status === 'unverifiable' + ) { + pty.connected = false + pty.disconnectedAt ??= Date.now() + continue + } + const observed = this.ptyController.hasPty?.(pty.ptyId) + if (observed === true) { + // Why: an SSH spawn can become addressable before an overlapping relay list includes it. + allLivePtyIds.add(pty.ptyId) + if ( + !targetWorktreeId || + (pty.worktreeId && worktreeIdsEqual(pty.worktreeId, targetWorktreeId)) + ) { + selectedLivePtyIds.add(pty.ptyId) + } + pty.connected = true + pty.disconnectedAt = null + this.forgetPtyLivenessVerdict(pty.ptyId) + continue + } + pty.connected = false + pty.disconnectedAt ??= Date.now() + pty.agentSessionOwners = [] + // Why: this list only enumerates registered providers, so a dropped relay + // clears `connected` for every one of its PTYs at once. Only `false` here + // is an observed absence; `null` means no provider could be asked. + if (observed === false) { + this.forgetPtyLivenessVerdict(pty.ptyId) + } else if (observed === null) { + this.markPtyLivenessUnverifiable(pty.ptyId, NO_OBSERVING_PROVIDER_REASON) + } + } + } + // Why: runs after the hasPty rescue so a still-addressable pane keeps its receipt. + // A provider that failed to list is absent from `sessions`, and dropping authority on + // that silence would retire an orchestration handle the relay can still reach. + for (const [ptyId, receipt] of this.restoredOrchestrationAuthorityByPtyId) { + const inScope = + connectionId === undefined || + (connectionId === null && receipt.hostScope.kind !== 'ssh') || + (typeof connectionId === 'string' && + receipt.hostScope.kind === 'ssh' && + receipt.hostScope.targetId === connectionId) + if (inScope && !allLivePtyIds.has(ptyId)) { + this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) + } + } + this.pruneDisconnectedPtyRecords() + return { + livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, + allLivePtyIds, + terminalIdentityByPtyId: controllerIdentityByPtyId, + queriedHostIds + } + } + + private refreshFloatingWorkspacePtyLiveness(): Set | null { + const controller = this.ptyController + if (!controller?.hasPty) { + return null + } + const knownPtyIds = new Set() + const persistedBindingByPtyId = new Map() + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + knownPtyIds.add(pty.ptyId) + } + } + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { + knownPtyIds.add(leaf.ptyId) + } + } + const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) + for (const tab of snapshot?.tabs ?? []) { + if (tab.type !== 'terminal') { + continue + } + if (tab.ptyId) { + knownPtyIds.add(tab.ptyId) + persistedBindingByPtyId.set(tab.ptyId, { + tabId: tab.parentTabId, + paneKey: this.getMobileTerminalPaneKey(tab) + }) + } + for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { + knownPtyIds.add(ptyId) + persistedBindingByPtyId.set(ptyId, { + tabId: tab.parentTabId, + paneKey: isTerminalLeafId(leafId) + ? makePaneKey(tab.parentTabId, leafId) + : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` + }) + } + } + + const liveness = new Map() + try { + for (const ptyId of knownPtyIds) { + const live = controller.hasPty(ptyId) + if (live === null) { + return null + } + liveness.set(ptyId, live) + } + } catch { + return null + } + + const livePtyIds = new Set() + for (const [ptyId, live] of liveness) { + let pty = this.ptysById.get(ptyId) + if (live) { + livePtyIds.add(ptyId) + const binding = persistedBindingByPtyId.get(ptyId) + if (!pty && binding) { + // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. + pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { + connected: true, + tabId: binding.tabId, + paneKey: binding.paneKey + }) + } + if (pty) { + pty.connected = true + pty.disconnectedAt = null + this.forgetPtyLivenessVerdict(ptyId) + this.refreshPtyForegroundAgent(ptyId) + } + } else if (pty && !this.leafExistsForPty(ptyId)) { + pty.connected = false + pty.disconnectedAt ??= Date.now() + } + } + this.pruneDisconnectedPtyRecords() + return livePtyIds + } + + private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { + if (pty.connected) { + return + } + // Why: disconnected PTY records stay addressable for status/exit reads, but their transcripts must not accumulate after the process dies. + pty.tailBuffer = [] + pty.tailTranscriptBuffer = [] + pty.tailTranscriptChars = 0 + pty.tailPartialLine = '' + pty.tailPendingAnsi = '' + pty.tailRedrawCursor = null + pty.tailTruncated = false + pty.tailLinesTotal = 0 + pty.waitBlockedAt = null + // Why: tail is now empty, so clear the memoized wait scan; onPtyData must recompute from the reset tail if this record resumes output. + pty.tailWaitState = undefined + } + + private pruneDisconnectedPtyRecords(): void { + const retained = [...this.ptysById.values()] + .filter((pty) => !pty.connected && !this.leafExistsForPty(pty.ptyId)) + .sort((a, b) => (a.disconnectedAt ?? 0) - (b.disconnectedAt ?? 0)) + const staleCount = Math.max(0, retained.length - DISCONNECTED_PTY_RECORD_MAX) + for (const stale of retained.slice(0, staleCount)) { + // Why: exited runtime-owned PTYs stay readable, but long-lived runtimes churn through many sessions; bound the archive. + this.dropDisconnectedPtyRecord(stale.ptyId) + } + } + + private dropDisconnectedPtyRecord(ptyId: string): void { + // Why: pruning can remove a PTY without the normal exit callback. + this.advancePtyLifecycleGeneration(ptyId) + this.pairedRendererSessionOwnedPtyIds.delete(ptyId) + this.ptysById.delete(ptyId) + this.pendingPtyHandleReplacementFences.delete(ptyId) + this.recentPtyOutputById.delete(ptyId) + this.setupCompletionTokenByPtyId.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) + this.recentPtyPathCandidatesById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) + this.invalidatePtyIncarnationHandle(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + this.clearAgentRowSnapshotsForPty(ptyId) + const handle = this.handleByPtyId.get(ptyId) + if (handle) { + // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + this.handleByPtyId.delete(ptyId) + this.syntheticTerminalHandles.delete(handle) + const record = this.handles.get(handle) + if (record?.tabId.startsWith('pty:')) { + this.handles.delete(handle) + } + } + } + + private leafExistsForPty(ptyId: string): boolean { + return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 + } + + private rebuildLeafPtyIndex(): void { + const next = new Map() + for (const leaf of this.leaves.values()) { + if (!leaf.ptyId) { + continue + } + const leaves = next.get(leaf.ptyId) + if (leaves) { + leaves.push(leaf) + } else { + next.set(leaf.ptyId, [leaf]) + } + } + this.leavesByPtyId = next + } + + private getLeavesForPty(ptyId: string): RuntimeLeafRecord[] { + return this.leavesByPtyId.get(ptyId) ?? [] + } + + private getSummaryForRuntimeWorktreeId( + summaries: Map, + runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds: Set, + runtimeWorktreeId: string + ): RuntimeWorktreePsSummary | null { + const exact = summaries.get(runtimeWorktreeId) + if (exact) { + return exact + } + if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) { + return null + } + const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) + if (!parsed) { + return null + } + const comparisonPlatform = + runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform + const indexed = findRuntimeWorktreeSummaryByPath( + runtimeWorktreeSummaryPathIndex, + parsed.repoId, + parsed.worktreePath, + comparisonPlatform + ) + if (indexed) { + return indexed + } + missingRuntimeWorktreeIds.add(runtimeWorktreeId) + return null + } + + /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */ + private resolvePaneAgentIdentityField( + launchAgent: TuiAgent | null | undefined, + foregroundAgent: TuiAgent | null | undefined, + title: string | null, + paneKey: string | null + ): { agentIdentity?: TuiAgent } { + // Why hooks here: an agent the USER started from a shell has no launch record, and on WSL the + // Windows host reads its foreground process as `wsl.exe` rather than the agent inside the + // distro. The hook is the only signal that survives both, because the agent reports itself. + const hookRow = paneKey + ? this.getHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) + : null + const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null + const agentIdentity = resolvePublishedPaneAgentIdentity({ + hookAgent, + hookIsLive: hookRow?.agentIsLive, + launchAgent, + foregroundAgent, + title + }) + return agentIdentity ? { agentIdentity } : {} + } + + private buildTerminalSummary( + leaf: RuntimeLeafRecord, + worktreesById: Map, + provenLivePtyIds: ReadonlySet | null = null + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(leaf.worktreeId) + const tab = this.tabs.get(leaf.tabId) ?? null + + const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + const title = getLatestLeafTitle(leaf, tab?.title ?? null) + // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a + // restored surface whose PTY died with a prior run still reads connected. + // Demote only on a controller-proven absence, and only for locally-scoped + // ids the aggregate inventory authoritatively covers — SSH/remote scopes may + // be legitimately missing from it, and unknown liveness never demotes. + // The sync hasPty rescue closes the spawn/list race: a just-spawned PTY can + // register after the inventory snapshot, and federation reads one + // connected:false as exited. + const provenAbsent = + provenLivePtyIds !== null && + leaf.ptyId !== null && + !provenLivePtyIds.has(leaf.ptyId) && + !leaf.ptyId.startsWith('remote:') && + parseAppSshPtyId(leaf.ptyId) === null && + this.ptyController?.hasPty?.(leaf.ptyId) !== true + return { + handle: this.issueHandle(leaf), + ptyId: leaf.ptyId, + incarnationId: pty?.incarnationId ?? null, + orphaned: false, + worktreeId: leaf.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: leaf.tabId, + leafId: leaf.leafId, + title, + connected: provenAbsent ? false : leaf.connected, + writable: provenAbsent ? false : leaf.writable, + lastOutputAt: leaf.lastOutputAt, + preview: leaf.preview, + ...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}), + ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty?.launchAgent, + pty?.foregroundAgent, + title, + // Why guarded: makePaneKey THROWS on a non-UUID leaf id, and an unguarded call here took + // down terminal.list for every pane in the list, not just the odd one. + isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null + ) + } + } + + // Why: the PTY id names its own host when it has one; only a host-less id may + // fall back to the worktree's. A foreign id with no owner stays unset rather + // than inheriting a local worktree's host and reading as local. + private terminalExecutionHostField( + ptyId: string | null, + worktreeId: string + ): { executionHostId?: ExecutionHostId } { + const fromPtyId = getPtyExecutionHost(ptyId) + if (fromPtyId === 'foreign') { + return {} + } + const hostId = fromPtyId ?? this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return hostId ? { executionHostId: hostId } : {} + } + + // Returns the worktrees whose stored snapshot object changed during this + // sync, so the caller can fan out only actually-changed worktrees. + private syncMobileSessionTabs( + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, + unchangedWorktreeIds?: string[], + resyncWorktreeIds = new Set() + ): Set { + const changedWorktreeIds = new Set() + if (snapshots === undefined) { + return changedWorktreeIds + } + // Why: snapshots are immutable — every writer replaces the map entry with a + // new object, and the accept gate below drops semantically-unchanged + // renderer resends before they replace an entry — so reference identity + // before/after detects exactly the entries that actually changed. + const before = new Map(this.mobileSessionTabsByWorktree) + this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, { + missingSnapshotOnly: true, + notify: false + }) + // Why: graph sync must scan each persisted host session once, not once per workspace. + const worktreeSessionsToHydrate = new Map( + this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) + ) + if (this.offscreenBrowserBackend) { + for (const snapshot of snapshots) { + if (!worktreeSessionsToHydrate.has(snapshot.worktree)) { + worktreeSessionsToHydrate.set(snapshot.worktree, null) + } + } + } + // Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition. + for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true, + ...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {}) + }) + } + const nextWorktrees = new Set() + const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + // Why: the renderer withholds unchanged snapshots to keep the graph payload + // small, so these worktrees are still live and must not fall into the prune + // below. Ask for a republish when main no longer holds that accepted renderer + // publication or a formerly-preserved runtime tab has gone stale. + for (const worktreeId of unchangedWorktreeIds ?? []) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + if (existing) { + nextWorktrees.add(worktreeId) + } + if ( + existing && + accepted && + (existing.publicationEpoch === accepted.publicationEpoch || + existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && + existing.tabs.length >= accepted.rendererTabCount && + (existing.tabs.length === accepted.rendererTabCount || + !this.storedMobileSnapshotHasStalePreservedTab( + existing, + accepted.rendererTabIdentityKeys + )) + ) { + continue + } + if (!incomingWorktreeIds.has(worktreeId)) { + resyncWorktreeIds.add(worktreeId) + } + // Why: the accept gate compares against the renderer's last accepted pair, + // which outlives the dropped snapshot and would reject the republish. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + } + for (const snapshot of snapshots) { + nextWorktrees.add(snapshot.worktree) + const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) + // Why: judge renderer publication ordering against the renderer's own + // last-accepted (epoch, version) — the renderer reuses one pair for + // byte-identical content, so a same-epoch version <= the accepted one is + // a no-op resend (or a stale frame) and must be skipped. Never compare + // against the stored snapshot's version: main-local touches bump it + // independently and would reject genuinely newer renderer revisions. + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) + if ( + accepted && + accepted.publicationEpoch === snapshot.publicationEpoch && + snapshot.snapshotVersion <= accepted.rendererVersion && + // Why: preservation is main-only state — a serve/SSH binding (or live + // browser page) can disappear without the renderer bumping its version, + // so a resend of the EXACT accepted revision (content-identical to the + // accepted publication, safe to re-merge) must still fall through to + // the merge, which prunes stale preserved tabs. Strictly-older frames + // stay skipped: their content is outdated, and the next accepted-pair + // resend performs the prune. + !( + existing && + snapshot.snapshotVersion === accepted.rendererVersion && + this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) + ) + ) { + continue + } + this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) + const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) + this.releaseRuntimeSessionOwnershipForRendererRetiredTabs(fencedSnapshot, existing) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) + // Why: clients drop same-epoch frames whose version isn't strictly newer, + // and main-local touches may already have emitted a higher version than + // the renderer's counter — keep the stored version strictly monotonic so + // the accepted content is never discarded as stale downstream. + const storedVersion = existing + ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) + : nextSnapshot.snapshotVersion + this.mobileSessionTabsByWorktree.set( + snapshot.worktree, + storedVersion === nextSnapshot.snapshotVersion + ? nextSnapshot + : { ...nextSnapshot, snapshotVersion: storedVersion } + ) + this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + rendererVersion: snapshot.snapshotVersion, + rendererTabCount: fencedSnapshot.tabs.length, + rendererTabIdentityKeys: new Set( + fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) + }) + } + for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { + if (!nextWorktrees.has(worktreeId)) { + const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) + if (preserved) { + // Why: preservation filters existing.tabs in place (same objects) and + // the merge epoch hashes the preserved identities idempotently, so an + // equal epoch with every tab object retained means the recomputation + // was a no-op — keep the entry so no-op syncs don't fan out. + const preservedIsNoOp = + preserved.publicationEpoch === existing.publicationEpoch && + preserved.tabs.length === existing.tabs.length && + preserved.tabs.every((tab, index) => tab === existing.tabs[index]) + if (!preservedIsNoOp) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + } + // Why: the stored entry is no longer the renderer's publication, so a + // future renderer frame must be re-merged even if it reuses the pair. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + nextWorktrees.add(worktreeId) + } else { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. + this.cancelScheduledMobileSessionTabsChanged(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) + } + } + } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (before.get(worktreeId) !== snapshot) { + changedWorktreeIds.add(worktreeId) + } + } + return changedWorktreeIds + } + + private mergePreservedHeadlessMobileSessionTabs( + snapshot: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): RuntimeMobileSessionTabsSnapshot { + if (!existing) { + return snapshot + } + const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) + if (preservedTabs.length === 0) { + return snapshot + } + const preservedActiveTab = preservedTabs.find( + (tab) => tab.id === existing.activeTabId && tab.isActive + ) + const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) + const normalizedPreservedTabs = preservedTabs.map((tab) => + hasIncomingActiveTab && !preservedActiveTab ? { ...tab, isActive: false } : tab + ) + // Why: an omitting renderer frame predates the runtime-owned structured + // publication, so it cannot revoke that publication's focus intent. + const normalizedIncomingTabs = preservedActiveTab + ? snapshot.tabs.map((tab) => (tab.isActive ? { ...tab, isActive: false } : tab)) + : snapshot.tabs + const tabs = this.mergeMobileSessionSnapshotTabs( + normalizedIncomingTabs, + normalizedPreservedTabs + ) + if (tabs.length === snapshot.tabs.length) { + return snapshot + } + const activeTab = + preservedActiveTab ?? + normalizedIncomingTabs.find((tab) => tab.id === snapshot.activeTabId) ?? + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + const tabGroups = this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ) + return { + ...snapshot, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch( + snapshot, + normalizedPreservedTabs + ), + snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), + activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeStructuredAgentSessionTabGroups( + tabGroups, + existing.tabGroups ?? [], + normalizedPreservedTabs, + activeTab?.id ?? null + ), + tabs + } + } + + private mergeStructuredAgentSessionTabGroups( + groups: readonly RuntimeMobileSessionTabGroup[], + existingGroups: readonly RuntimeMobileSessionTabGroup[], + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTabId: string | null + ): RuntimeMobileSessionTabGroup[] { + const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session') + if (structuredTabs.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + for (const tab of structuredTabs) { + const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id + const target = next.find((group) => group.id === priorGroupId) ?? next[0] + if (target && !target.tabOrder.includes(tab.id)) { + target.tabOrder.push(tab.id) + } + if (target && tab.id === activeTabId) { + target.activeTabId = tab.id + } + } + return next + } + + private buildPreservedHeadlessMobileSessionSnapshot( + existing: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot | null { + const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) + if (tabs.length === 0) { + return null + } + const activeTab = + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...existing, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + // Why: mint a fresh version or clients' same-epoch gate drops the prune frame. + snapshotVersion: existing.snapshotVersion + 1, + activeGroupId: + existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + existing.worktree, + existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + // Why: the accepted-revision no-op gate must not fossilize preserved runtime + // tabs. A stored merged snapshot's tabs absent from the accepted renderer + // publication exist only via preservation; if any such tab no longer + // passes the preservation predicate (binding removed from the live PTY table + // and persisted session, or browser page closed), the stored snapshot is stale. + private storedMobileSnapshotHasStalePreservedTab( + existing: RuntimeMobileSessionTabsSnapshot, + rendererTabIdentityKeys: ReadonlySet + ): boolean { + return existing.tabs.some( + (tab) => + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + rendererTabIdentityKeys.has(id) + ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + ) + } + + private collectPreservedHeadlessMobileSessionTabs( + existing: RuntimeMobileSessionTabsSnapshot, + incoming?: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionSnapshotTab[] { + const incomingIds = new Set( + incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] + ) + return existing.tabs.filter((tab) => { + if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { + return false + } + return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + }) + } + + private shouldPreserveHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionSnapshotTab + ): boolean { + if (tab.type === 'agent-session') { + return true + } + if (tab.type === 'browser') { + const liveClientPage = + typeof tab.browserPageId === 'string' + ? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId) + : undefined + if ( + liveClientPage?.workspaceId === snapshot.worktree && + tab.placement?.kind === 'client' && + sameRuntimeBrowserPlacement(liveClientPage.placement, tab.placement) + ) { + return true + } + // Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph". + if (!this.offscreenBrowserBackend) { + return false + } + // Why: in a renderer-based merged snapshot the browser entries can also + // be renderer-owned, so only pages the offscreen bridge still lists are + // runtime-owned and preservable; a pure renderer epoch preserves none. + return ( + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + (snapshot.publicationEpoch.includes(':headless-merge:') && + typeof tab.browserPageId === 'string' && + this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)) + ) + } + if (tab.type !== 'terminal') { + return false + } + // Why: a merged renderer snapshot carries BOTH renderer-owned and + // runtime-owned tabs, so the epoch alone must not preserve every terminal — + // that resurrects renderer tabs the renderer already closed. Broad + // preservation applies only to genuinely headless-built snapshots; in a + // renderer-based one, only tabs with a live-or-persisted serve/SSH binding + // are runtime-owned and preservable. + return ( + this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) || + this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) + ) + } + + private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { + return ( + publicationEpoch.startsWith('headless:') || + publicationEpoch.startsWith('headless-hydrated:') || + publicationEpoch.includes(':headless-merge:') + ) + } + + // Why: `:headless-merge:` only marks that runtime tabs were merged in — the + // BASE epoch still says who published the snapshot. A renderer-based merged + // snapshot must not be classified as headless-built, or its renderer tabs + // read as runtime-owned. + private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean { + const base = publicationEpoch.split(':headless-merge:')[0] + return base.startsWith('headless:') || base.startsWith('headless-hydrated:') + } + + private getMergedMobileSessionPublicationEpoch( + snapshot: RuntimeMobileSessionTabsSnapshot, + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string { + // Why: preserved snapshots can merge repeatedly; strip the prior merge suffix first so the publication epoch stays idempotent. + const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] + const signature = createHash('sha1') + .update( + preservedTabs + .map((tab) => + tab.type === 'terminal' + ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` + : tab.id + ) + .join('|') + ) + .digest('hex') + .slice(0, 12) + return `${normalizedPublicationEpoch}:headless-merge:${signature}` + } + + private readonly clientHostedBrowserRows = new ClientHostedBrowserRowPublisher({ + listClientPages: (worktreeId) => getRuntimeBrowserPageRegistry(this).listPages(worktreeId), + hasLivePlacement: (browserPageId) => + getBrowserHostLeaseRegistry(this).getPlacement(browserPageId) !== undefined, + resolveDeviceName: (pairedDeviceId) => this.getPairedDeviceNameFn(pairedDeviceId), + getEmitter: () => { + const notifier = this.notifier + const send = notifier?.clientHostedBrowserRowsChanged + return send ? (event) => send.call(notifier, event) : null + } + }) + + /** Worktrees whose persisted client-hosted rows this runtime is responsible for rewriting. */ + private readonly persistedClientHostedBrowserWorktreeIds = new Set() + + /** Serves a hydrating host renderer; the publisher counts this as a delivery, not a read. */ + listClientHostedBrowserRows(): ClientHostedBrowserRowsEvent[] { + return this.clientHostedBrowserRows.deliverHydrationSnapshot() + } + + private notifyMobileSessionTabsRemoved(worktreeId: string): void { + const removed: RuntimeMobileSessionTabsRemovedResult = { + worktree: worktreeId, + publicationEpoch: `removed:${Date.now().toString(36)}`, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + const changeSequence = ++this.mobileSessionTabsChangeSequence + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.clientSessionTabSelections.project(removed, subscription.clientNavigationId), + changeSequence + ) + } + this.clientSessionTabSelections.forgetWorktree(worktreeId) + } + + notifyMobileSessionTabsChanged(worktreeId?: string): void { + if (!worktreeId) { + this.clientHostedBrowserRows.publishAll() + for (const id of new Set([ + ...this.persistedClientHostedBrowserWorktreeIds, + ...getRuntimeBrowserPageRegistry(this) + .listPages() + .map((page) => page.workspaceId) + ])) { + this.persistClientHostedBrowserPagesForWorktree(id) + } + this.notifyMobileSessionTabSnapshots() + return + } + // Why: every client-page mutation — create, navigate, metadata, host quit, recovery — reaches + // this announcement, so the host's own rows derive from it rather than from a second seam. + this.clientHostedBrowserRows.publish(worktreeId) + this.persistClientHostedBrowserPagesForWorktree(worktreeId) + const hasClientBrowserPages = + getRuntimeBrowserPageRegistry(this).listPages(worktreeId).length > 0 + if (this.offscreenBrowserBackend || hasClientBrowserPages) { + const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + worktreeId, + hasClientBrowserPages + ? { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true } + : undefined + ) + // Why: hydrate already reconciles an existing snapshot in place; only reconcile here when it didn't (fresh build or early-returned hydrate). + if (!reconciled.has(worktreeId)) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + if (existing) { + this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) + } + } + } + // Why: structural changes must propagate promptly; cancel any pending coalesced notify since this immediate emit supersedes it. + this.cancelScheduledMobileSessionTabsChanged(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) + } + + private scheduleMobileSessionTabsChanged(worktreeId: string): void { + this.pendingMobileSessionTabsChangeSequenceByWorktree.set( + worktreeId, + ++this.mobileSessionTabsChangeSequence + ) + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } + + private cancelScheduledMobileSessionTabsChanged(worktreeId: string): void { + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) + } + + private flushScheduledMobileSessionTabsChanged(worktreeId: string): void { + const changeSequence = this.pendingMobileSessionTabsChangeSequenceByWorktree.get(worktreeId) + if (changeSequence === undefined) { + return + } + this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId, changeSequence) + } + + private notifyMobileSessionTabsChangedNow(worktreeId: string, changeSequence: number): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + // Why: browser bridge events are already worktree-scoped; don't fan out every workspace snapshot during navigation/tab churn. + const result = this.toMobileSessionTabsResult(snapshot) + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) + } + } + + private notifyMobileSessionTabSnapshots(): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + for (const snapshot of this.mobileSessionTabsByWorktree.values()) { + const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence + for (const subscription of this.mobileSessionTabListeners) { + subscription.listener( + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence + ) + } + } + } + + private getMobileSessionTabsForWorktree( + worktreeId: string, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return this.projectMobileSessionTabsForClient( + { + worktree: worktreeId, + publicationEpoch: UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH, + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + clientNavigationId + ) + } + return this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + } + + private emitMobileSessionTabsSnapshotToClient( + projected: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + follow = false + ): void { + const changeSequence = ++this.mobileSessionTabsChangeSequence + for (const subscription of this.mobileSessionTabListeners) { + if (subscription.clientNavigationId === clientNavigationId) { + subscription.listener( + follow ? { ...projected, navigationIntent: 'follow' } : projected, + changeSequence + ) + } + } + } + + private async resolveMobileMarkdownWorktreeId( + worktreeSelector: string, + tabId: string + ): Promise { + const worktreeId = + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionMarkdownTab => + candidate.type === 'markdown' && candidate.id === tabId + ) + if (!tab) { + throw new Error('tab_not_found') + } + return worktreeId + } + + private getLiveBrowserTabsByPageId(worktreeId: string): Map { + const liveTabs = this.agentBrowserBridge?.tabList?.(worktreeId).tabs ?? [] + const byPageId = new Map(liveTabs.map((tab) => [tab.browserPageId, tab])) + for (const [index, page] of getRuntimeBrowserPageRegistry(this) + .listPages(worktreeId) + .entries()) { + byPageId.set(page.browserPageId, { + browserPageId: page.browserPageId, + index: liveTabs.length + index, + url: page.url, + title: page.title, + active: page.active, + worktreeId, + profileId: page.browserProfileId + }) + } + return byPageId + } + + private collectReturnedSessionTabIds( + tabs: readonly RuntimeMobileSessionClientTab[] + ): Set { + const ids = new Set() + for (const tab of tabs) { + ids.add(tab.id) + if (tab.type === 'terminal') { + ids.add(tab.parentTabId) + } else if (tab.type === 'browser') { + ids.add(tab.browserWorkspaceId) + } + } + return ids + } + + private sanitizeMobileSessionTabGroups( + groups: readonly RuntimeMobileSessionTabGroup[] | undefined, + returnedTabs: readonly RuntimeMobileSessionClientTab[] + ): RuntimeMobileSessionTabGroup[] | undefined { + if (!groups || groups.length === 0) { + return undefined + } + const returnedIds = this.collectReturnedSessionTabIds(returnedTabs) + const sanitized = groups + .map((group): RuntimeMobileSessionTabGroup | null => { + const tabOrder = group.tabOrder.filter((tabId) => returnedIds.has(tabId)) + if (tabOrder.length === 0) { + return null + } + const activeTabId = + group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) + return { + id: group.id, + activeTabId, + tabOrder, + ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) + } + }) + .filter((group): group is RuntimeMobileSessionTabGroup => group !== null) + return sanitized.length > 0 ? sanitized : undefined + } + + private pruneMobileSessionTabGroupLayout( + layout: TabGroupLayoutNode | null | undefined, + validGroupIds: ReadonlySet + ): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return validGroupIds.has(layout.groupId) ? layout : null + } + const first = this.pruneMobileSessionTabGroupLayout(layout.first, validGroupIds) + const second = this.pruneMobileSessionTabGroupLayout(layout.second, validGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second + } + + /** Transforms an internal mobile session tab snapshot into a sanitized client payload, resolving launch-agent ownership and normalizing titles. */ + private toMobileSessionTabsResult( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsResult { + const tabs: RuntimeMobileSessionClientTab[] = [] + const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) + // Production reads hook rows by pane; the snapshot fallback remains for tests + // and embedders that have not adopted the narrow getter. + let hookRowsByPaneKey: Map | null = null + const hookRowsForPane = new Map() + const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { + const cached = hookRowsForPane.get(paneKey) + if (cached) { + return cached + } + const direct = this.getAgentProviderSessionRowsForPaneFn?.(paneKey) + if (direct) { + hookRowsForPane.set(paneKey, direct) + return direct + } + hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey( + this.getAgentProviderSessionSnapshotFn?.() ?? [] + ) + const rows = hookRowsByPaneKey.get(paneKey) ?? [] + hookRowsForPane.set(paneKey, rows) + return rows + } + // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. + const claimedLivePtyIds = new Set() + for (const tab of snapshot.tabs) { + if (tab.type === 'browser') { + const liveTab = tab.browserPageId + ? liveBrowserTabsByPageId.get(tab.browserPageId) + : undefined + if (!liveTab) { + continue + } + // Why: renderer snapshots lag BrowserView teardown/process swaps; only surface pages the browser bridge can still route to. + tabs.push({ + ...tab, + title: liveTab.title || tab.title, + url: liveTab.url || tab.url, + // Why: bridge "active" means active BrowserView/webContents, not active Orca tab; preserve the renderer's session focus. + isActive: tab.isActive + }) + continue + } + if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') { + tabs.push(tab) + continue + } + const syncedTab = this.tabs.get(tab.parentTabId) + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null + const liveLeafPtyId = liveLeaf?.ptyId ?? null + const liveLeafPty = liveLeafPtyId ? (this.ptysById.get(liveLeafPtyId) ?? null) : null + const pty = liveLeaf + ? null + : this.findPtyForMobileTerminalTab(snapshot.worktree, tab, { + allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless') + }) + const livePty = pty?.connected ? pty : null + // Why: enforce one-live-PTY-per-tab; drop a later tab resolving to an already-claimed PTY so no two tabs share a handle. + const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null + if (resolvedLivePtyId !== null) { + if (claimedLivePtyIds.has(resolvedLivePtyId)) { + continue + } + claimedLivePtyIds.add(resolvedLivePtyId) + } + const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null + const paneKey = isTerminalLeafId(tab.leafId) + ? makePaneKey(tab.parentTabId, tab.leafId) + : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const mobileStatusPty = livePty ?? pty + // Why: headless hooks live only in main's retained rows; reuse this lookup + // for both title ownership and status publication so the two cannot diverge. + const retainedAgentStatus = tab.agentStatus + ? null + : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) + const hookAgentStatus = tab.agentStatus + ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + : null + // Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned + // null, because persisted ids can collide with an unrelated pane after restart — reading + // that pane's tracker would publish its title here, ahead of every other source. + const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( + liveLeafPtyId ?? pty?.ptyId ?? null + ) + const leafTitle = leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : null + // Renderer omission is authoritative: PTY launch provenance outlives agent exit. + const launchAgent = tab.launchAgent ?? null + const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: launchOwnerAgent, + hookAgent: + tab.agentStatus?.agentType ?? + hookAgentStatus?.agentType ?? + retainedAgentStatus?.payload.agentType ?? + null + }) + const ownerAgent = + ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null + const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } + const title = normalizeCompatibleAgentTitleForOwner( + trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, + ownerAgent, + ownerOptions + ) + const liveTitleEvidence = leafTitle ?? ptyTitle + // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. + const rendererStatusAgent = + resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ?? + ownerAgent ?? + undefined + const hookSessionAgent = resolveCompatibleAgentTypeForOwner( + hookAgentStatus?.providerSessionAgentType, + ownerAgent, + ownerOptions + ) + const hookSessionMatchesRenderer = + !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent + const hookProviderSession = + hookAgentStatus?.providerSession && + hookSessionMatchesRenderer && + (!tab.agentStatus?.providerSession || + (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) + ? hookAgentStatus.providerSession + : tab.agentStatus?.providerSession + const statusPty = liveLeafPty ?? mobileStatusPty + const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle( + tab.agentStatus + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent, + ownerOptions + ) + : null, + statusPty, + { preserveQuestionUnderShellTitle: true } + ) + // Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title. + const hasLiveAgentSignal = + normalizedTabAgentStatus?.interactivePrompt != null || + normalizedTabAgentStatus?.toolName != null + // Why: only shell/management evidence proves the agent released the pane + // (same predicate as the terminal-status API). A merely neutral live title + // — 'Terminal', an editor, a cwd — proves nothing, and treating it as + // completion published a synthetic `done` that fought the client's own + // live status on every republication. + const keepFullAgentStatus = + normalizedTabAgentStatus && + (!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal) + const agentStatus = keepFullAgentStatus + ? { agentStatus: normalizedTabAgentStatus } + : // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript. + normalizedTabAgentStatus?.agentType != null + ? { + agentStatus: { + state: 'done' as const, + prompt: '', + updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt, + stateStartedAt: + statusPty?.lastAgentStatusStartedAtEpochMs ?? + normalizedTabAgentStatus.stateStartedAt, + paneKey: normalizedTabAgentStatus.paneKey, + stateHistory: [], + agentType: normalizedTabAgentStatus.agentType, + ...(normalizedTabAgentStatus.providerSession + ? { providerSession: normalizedTabAgentStatus.providerSession } + : {}) + } + } + : null + // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. + const terminalHandle = liveLeafPtyId + ? this.issuePtyHandle( + this.recordPtyWorktree(liveLeafPtyId, snapshot.worktree, { + tabId: tab.parentTabId, + paneKey, + connected: true + }) + ) + : livePty + ? this.issuePtyHandle(livePty) + : null + const projectedAgentStatus = + agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus, + getHookRowsForPane + ) + const projectedStatusEntry = projectedAgentStatus.agentStatus as + | (AgentStatusEntry & { turnCompletedAt?: number }) + | undefined + const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = + projectedStatusEntry ?? {} + const clientAgentStatus = projectedStatusEntry + ? { agentStatus: clientStatusFields as AgentStatusEntry } + : {} + const rawTurnCompletedAt = + hookAgentStatus?.live?.payload.turnCompletedAt ?? + this.getHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? + projectedTurnCompletedAt + const turnCompletedAt = + typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt) + ? rawTurnCompletedAt + : undefined + tabs.push({ + type: 'terminal', + id: tab.id, + parentTabId: tab.parentTabId, + leafId: tab.leafId, + title, + ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), + ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), + ...(launchAgent ? { launchAgent } : {}), + ...clientAgentStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), + ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), + ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), + ...(tab.color != null ? { color: tab.color } : {}), + ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), + ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), + ...(tab.launchDraftCreatedAt !== undefined + ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } + : {}), + isActive: tab.isActive, + ...(terminalHandle + ? { status: 'ready' as const, terminal: terminalHandle } + : { status: 'pending-handle' as const, terminal: null }) + }) + } + const active = + tabs.find((tab) => tab.isActive && tab.id === snapshot.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + (snapshot.activeTabId ? (tabs[0] ?? null) : null) + const normalizedTabs = + active && !tabs.some((tab) => tab.isActive) + ? tabs.map((tab) => (tab.id === active.id ? { ...tab, isActive: true } : tab)) + : tabs + const tabGroups = this.sanitizeMobileSessionTabGroups(snapshot.tabGroups, normalizedTabs) + const validGroupIds = new Set(tabGroups?.map((group) => group.id) ?? []) + const tabGroupLayout = + snapshot.tabGroupLayout === undefined + ? undefined + : this.pruneMobileSessionTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) + const activeGroupId = + snapshot.activeGroupId && validGroupIds.has(snapshot.activeGroupId) + ? snapshot.activeGroupId + : (tabGroups?.find((group) => + active + ? group.tabOrder.some((tabId) => + this.collectReturnedSessionTabIds([active]).has(tabId) + ) + : false + )?.id ?? + tabGroups?.[0]?.id ?? + null) + return { + worktree: snapshot.worktree, + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + activeGroupId, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(tabGroups ? { tabGroups } : {}), + ...(snapshot.tabGroupLayout !== undefined ? { tabGroupLayout } : {}), + ...(snapshot.retiredTerminalSurfaces + ? { + retiredTerminalSurfaces: snapshot.retiredTerminalSurfaces.filter( + (retired) => + !snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + tab.parentTabId === retired.parentTabId && + tab.leafId === retired.leafId + ) + ) + } + : {}), + tabs: normalizedTabs + } + } + + private renewMobileAgentStatusFromPtyTitle( + status: AgentStatusEntry | null, + pty: RuntimePtyWorktreeRecord | null, + options: { preserveQuestionUnderShellTitle?: boolean } = {} + ): AgentStatusEntry | null { + if (!status || !pty) { + return status + } + // Why: pending a human answer is hook-only evidence an idle title cannot renew. A title + // reads `permission` only from a vendor glyph or a synthesized ` - action required` + // label, and SYNTHETIC_AGENT_TITLE_PROFILES has no Claude entry (OpenCode opts out), so + // `titleConfirmsState` below is unreachable for them. Timestamps can't arbitrate either: + // lastAgentStatusRichInvalidatedAtEpochMs moves only on a status-CLASS change while + // lastOscTitleEpochMs moves on EVERY write, so Claude's one same-class repaint ~123ms + // after PermissionRequest pushed title evidence past a still-current hook and published + // `done` — retiring the card while the user was still being asked. + // Only under an `idle` title: idle is the ABSENCE of activity evidence, but a `working` + // title (agent resumed) or a null/shell/identity-only one (agent released the pane) + // contradicts the hook and must still retire the row and its stale question (#11761). + // Both names: Claude's PermissionRequest normalizes to `waiting`, not `blocked`. + if ( + (status.state === 'waiting' || status.state === 'blocked') && + pty.lastAgentStatus === 'idle' && + Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + ) { + return status + } + if ( + options.preserveQuestionUnderShellTitle && + status.interactivePrompt != null && + terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const richStatusCanOwnTitleInterval = + pty.lastAgentStatusRichInvalidatedAtEpochMs === null || + status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + const titleEvidenceAt = pty.lastOscTitleEpochMs + if (titleEvidenceAt === null) { + return richStatusCanOwnTitleInterval ? status : null + } + const buildTitleOnlyStatus = ( + state: AgentStatusEntry['state'], + updatedAt: number, + stateStartedAt: number + ): AgentStatusEntry => ({ + state, + prompt: '', + updatedAt, + stateStartedAt, + paneKey: status.paneKey, + stateHistory: [], + ...(status.agentType ? { agentType: status.agentType } : {}), + ...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}), + ...(status.worktreeId ? { worktreeId: status.worktreeId } : {}), + ...(status.tabId ? { tabId: status.tabId } : {}), + ...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}), + ...(status.providerSession ? { providerSession: status.providerSession } : {}) + }) + const titleConfirmsState = + (pty.lastAgentStatus === 'working' && status.state === 'working') || + (pty.lastAgentStatus === 'permission' && + (status.state === 'blocked' || status.state === 'waiting')) + if (!titleConfirmsState) { + if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + return status + } + if ( + pty.lastAgentStatus === null && + !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const titleState = + pty.lastAgentStatus === 'working' + ? 'working' + : pty.lastAgentStatus === 'permission' + ? 'blocked' + : 'done' + return buildTitleOnlyStatus( + titleState, + titleEvidenceAt, + pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt + ) + } + const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval + // Fresh explicit evidence from this title interval owns acknowledgement identity. + const stateStartedAt = richStatusOwnsCurrentState + ? status.stateStartedAt + : (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt) + if (richStatusOwnsCurrentState) { + pty.lastAgentStatusStartedAtEpochMs = stateStartedAt + } + const updatedAt = Math.max(status.updatedAt, titleEvidenceAt) + if (!richStatusOwnsCurrentState) { + return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt) + } + if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) { + return status + } + return { ...status, updatedAt, stateStartedAt } + } + + /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ + private buildPtyMobileAgentStatus( + pty: RuntimePtyWorktreeRecord | null, + tab: RuntimeMobileSessionTerminalTab, + terminalHandle: string | null, + retained: RuntimeAgentRowSnapshot | null, + getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] + ): { agentStatus: AgentStatusEntry } | Record { + const paneKey = this.getMobileTerminalPaneKey(tab) + // Why: neither the OSC-retained row nor a title-derived status can carry a + // provider session — only the hook payload does, and headless serve has no + // renderer to publish `tab.agentStatus`. Without it mobile native chat has no + // transcript to address and sits on the empty state forever. + const hookRow = this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + // Why: the hook row is evidence in its own right. Returning early on a missing + // PTY status/retained row put this check ahead of the only headless carrier, so + // an agent that reported its session but never emitted a recognized title got no + // `agentStatus` at all — exactly the hook-only case the fallback exists for. + if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { + return {} + } + const providerSession = hookRow.providerSession + ? { providerSession: hookRow.providerSession } + : {} + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( + pty?.ptyId ?? leaf?.ptyId ?? null + ) + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const ptyTitleClassification = classifyAgentTitle(ptyTitle) + const nonAgentTitle = ptyTitle !== null && ptyTitleClassification !== 'agent' + if (nonAgentTitle) { + // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. + const hasLiveHookSignal = + retained?.payload.interactivePrompt != null || + retained?.payload.toolName != null || + // Why: a pending question is never inherited across hook events (unlike + // `toolName`), so it proves the agent is parked on a selector right now. + hookRow.live?.payload.interactivePrompt != null || + // Why: headless serve has no renderer to retain an OSC row, so a fresh hook + // agentType is the only live signal a hook-only pane can offer — and an agent + // that reports over HTTP need never set a title this gate would recognize. + // Scoped to panes with no PTY status at all, so it cannot revive a spinner: + // this branch publishes `done`. It only keeps the transcript addressable. + (!pty?.lastAgentStatus && (hookRow.agentType != null || hookRow.providerSession != null)) + if (!hasLiveHookSignal) { + return {} + } + } + // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, + hookAgent: retained?.payload.agentType ?? hookRow.agentType + }) + const ownerAgent = ownerRecord?.agent ?? pty?.foregroundAgent ?? null + const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } + const terminalTitle = normalizeCompatibleAgentTitleForOwner( + trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, + ownerAgent, + ownerOptions + ) + // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). + const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) + if (liveRow) { + const liveStatus = normalizeCompatibleAgentStatusEntryForOwner( + { + ...liveRow.payload, + paneKey, + updatedAt: liveRow.updatedAt, + stateStartedAt: liveRow.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? liveRow.worktreeId) + ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle, + ...providerSession + }, + ownerAgent, + ownerOptions + ) + // A live question outranks only the shell title that currently obscures it. + const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { + preserveQuestionUnderShellTitle: true + }) + if (renewedStatus) { + return { agentStatus: renewedStatus } + } + } + // Last resort: the pane's hook evidence is identity only (resume rows, stale + // rows, or a row the freshness gate rejected). `done` is the honest + // projection — and it is what retires the card once the agent exits. + // Why not lastOutputAt: this state is title-derived, so it must be dated by + // its evidence. Stamping it with the byte stream made the frame advance on + // every output byte, so a paired client's live status could never outrank it. + const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now() + const agentType = ownerAgent ?? undefined + return { + agentStatus: { + state: + pty?.lastAgentStatus === 'working' + ? 'working' + : pty?.lastAgentStatus === 'permission' + ? 'blocked' + : 'done', + prompt: '', + updatedAt: evidenceAt, + stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt, + paneKey, + ...(terminalHandle ? { terminalHandle } : {}), + ...(agentType ? { agentType } : {}), + ...(pty?.worktreeId ? { worktreeId: pty.worktreeId } : {}), + tabId: tab.parentTabId, + terminalTitle, + stateHistory: [], + ...providerSession + } + } + } + + /** Live hook status to publish for a pane with no retained OSC row, or null when the + * pane's hook evidence only proves identity. + * + * Why the freshness rule: `pty.lastAgentStatus` is title-derived and refreshed live, + * so an unconditional hook precedence would let a 29-minute-old `done` erase a pane + * that is visibly working. A pending `interactivePrompt` outranks title evidence at + * any age — the agent is parked on a selector until it answers — and it is also the + * only signal allowed to survive the #1437 non-agent-title suppression. */ + private resolveHookLiveAgentRow( + live: HookLiveAgentRow | null, + pty: RuntimePtyWorktreeRecord | null, + nonAgentTitle: boolean + ): HookLiveAgentRow | null { + if (!live) { + return null + } + if (live.payload.interactivePrompt != null) { + return live + } + // Why only this stamp: it is the sole wall-clock date on the pane's live title, + // so it is the only one comparable to a hook `receivedAt`. The sibling + // `titleUpdatedAt`/`lastOscTitleAt`/`paneTitleUpdatedAt` fields are observation + // sequence numbers, and comparing them here can only ever misfire. + return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null + } + + /** Hook-reported identity for this pane, newest wins per field. + * + * `providerSession` is deliberately unbounded: it is resume identity, not live + * state, it stays correct until the pane relaunches (which overwrites the row + * under the same paneKey), and it is only ever read once an agent is already + * established. Bounding it would blank mobile native chat on an idle session. + * + * `agentType` is bounded by the same staleness window the retained OSC path uses, + * because it is the signal that claims an agent owns the pane at all. A user who + * exits the agent leaves `pty.lastAgentStatus` behind forever, so an unbounded + * read would keep offering native chat for what is now a plain shell. + * + * `live` is the newest fresh row's status fields — bounded like `agentType` because + * it asserts liveness, and unlike `agentType` it excludes `providerSessionOnly` rows + * with no Pi exception: those carry resume identity, and their status-shaped fields + * are documented transport placeholders that must never reach a client. It also drops + * `restoredUnconfirmed` rows, which `isFreshNonDoneAgentStatus` already treats as + * never-fresh; they still count as `agentType` identity evidence. */ + private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { + providerSession: AgentProviderSessionMetadata | null + providerSessionAgentType: string | null + providerSessionReceivedAt: number | null + agentType: string | null + agentIsLive: boolean + live: HookLiveAgentRow | null + } { + let session: AgentStatusIpcPayload | null = null + let agent: AgentStatusIpcPayload | null = null + let live: AgentStatusIpcPayload | null = null + const agentTypeFreshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS + // Why pane key only: the sibling `terminalHandle` arm this used to carry never + // matched. `toAgentStatusIpcPayload` does not emit the field on the hook path + // (only the renderer's own store stamps it, and headless serve has no renderer), + // and because it is optional TypeScript could not flag the dead comparison. + for (const entry of rows) { + if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { + session = entry + } + if ( + entry.agentType && + (entry.providerSessionOnly !== true || + (entry.agentType === 'pi' && entry.providerSession != null)) && + entry.receivedAt >= agentTypeFreshAfter && + (!agent || entry.receivedAt > agent.receivedAt) + ) { + agent = entry + } + if ( + entry.providerSessionOnly !== true && + // Why: a row hydrated from last-status.json describes a turn that may have ended + // while no receiver was up (#12346), so its `receivedAt` cannot prove liveness — + // publishing it would resurrect a zombie question card across a restart. + entry.restoredUnconfirmed !== true && + entry.receivedAt >= agentTypeFreshAfter && + (!live || entry.receivedAt > live.receivedAt) + ) { + live = entry + } + } + return { + providerSession: session?.providerSession ?? null, + providerSessionAgentType: session?.agentType ?? null, + providerSessionReceivedAt: session?.receivedAt ?? null, + agentType: agent?.agentType ?? null, + // A fresh completed row still projects its terminal `done` status, but it is + // past-tense identity evidence and must not outrank process or launch facts. + agentIsLive: agent != null && agent.state !== 'done', + live: live + ? { + payload: pickParsedAgentStatusPayload(live), + updatedAt: live.receivedAt, + stateStartedAt: live.stateStartedAt ?? live.receivedAt, + ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) + } + : null + } + } + + /** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */ + private getFreshRetainedAgentStatusForMobileTab( + paneKey: string, + pty: RuntimePtyWorktreeRecord | null, + tab: RuntimeMobileSessionTerminalTab + ): RuntimeAgentRowSnapshot | null { + let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null + if (!retained) { + const ptyId = pty?.ptyId ?? tab.ptyId ?? null + if (ptyId) { + for (const snapshot of this.latestAgentStatusByPaneKey.values()) { + if (snapshot.ptyId !== ptyId) { + continue + } + if (!retained || snapshot.updatedAt > retained.updatedAt) { + retained = snapshot + } + } + } + } + if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return null + } + return retained + } + + private findPtyForMobileTerminalTab( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab, + options: { allowWorktreeOnlyMatch?: boolean } = {} + ): RuntimePtyWorktreeRecord | null { + const snapshotPtyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null + const paneKey = this.getMobileTerminalPaneKey(tab) + if (snapshotPtyId) { + const pty = this.ptysById.get(snapshotPtyId) + if (!pty) { + return null + } + // Why: persisted PTY ids can collide with unrelated provider ids after restart; only a matching spawn-time pane identity is safe to expose. + if (this.mobileTerminalTabMatchesPty(worktreeId, tab, pty, paneKey)) { + return pty + } + if ( + options.allowWorktreeOnlyMatch === true && + pty.worktreeId === worktreeId && + pty.tabId === null && + pty.paneKey === null + ) { + return pty + } + return null + } + const paneKeys = new Set([`${tab.parentTabId}:${tab.leafId}`]) + if (tab.leafId === `pane:${FIRST_PANE_ID}`) { + paneKeys.add(`${tab.parentTabId}:${FIRST_PANE_ID}`) + } + for (const pty of this.ptysById.values()) { + if (pty.tabId === tab.parentTabId && pty.paneKey && paneKeys.has(pty.paneKey)) { + return pty + } + } + return null + } + + private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { + if (isTerminalLeafId(tab.leafId)) { + return makePaneKey(tab.parentTabId, tab.leafId) + } + const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null + return `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + } + + private mobileTerminalTabMatchesPty( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab, + pty: RuntimePtyWorktreeRecord, + paneKey = this.getMobileTerminalPaneKey(tab) + ): boolean { + return pty.worktreeId === worktreeId && pty.tabId === tab.parentTabId && pty.paneKey === paneKey + } + + // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. + getAgentStatusForHandle(handle: string): string | null { + try { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus + } catch { + return null + } + } + + getAgentStatusOrchestrationContextForPaneKey( + paneKey: string + ): AgentStatusOrchestrationContext | undefined { + const handle = this.getTerminalHandleForPaneKey(paneKey) + if (!handle) { + return undefined + } + return this.getAgentStatusOrchestrationContextForHandle(handle) + } + + getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined { + return this.getTerminalHandleForPaneKey(paneKey) ?? undefined + } + + getAgentStatusLaunchConfigForPaneKey( + paneKey: string, + args?: { launchToken?: string } + ): SleepingAgentLaunchConfig | undefined { + const pty = this.getPtyRecordForPaneKey(paneKey) + if (!pty?.launchConfig) { + return undefined + } + if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { + return undefined + } + return copySleepingAgentLaunchConfig(pty.launchConfig) + } + + private buildAgentOrchestrationByPaneKey(): + | Record + | undefined { + const db = this.getOrchestrationDbIfAvailable() + if (!db) { + return undefined + } + // Why: this runs on every 16ms graph publish (title/status churn). With no + // dispatch rows — the overwhelming majority who never orchestrate — the + // per-terminal query fan-out below can only ever yield an empty result, so + // skip it wholesale via the DB's cached emptiness probe. Optional call so + // partial test-injected DBs without the probe fall through to the scan. + if (db.hasAnyDispatchContexts?.() === false) { + return undefined + } + const contexts: Record = {} + const queriedHandles = new Set() + for (const leaf of this.leaves.values()) { + if (!leaf.ptyId) { + continue + } + const handle = this.issueHandle(leaf) + queriedHandles.add(handle) + const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) + if (context) { + contexts[this.makeRuntimePaneKey(leaf)] = context + } + } + for (const pty of this.ptysById.values()) { + if (!pty.paneKey || contexts[pty.paneKey]) { + continue + } + const handle = this.issuePtyHandle(pty) + if (queriedHandles.has(handle)) { + continue + } + queriedHandles.add(handle) + const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) + if (context) { + contexts[pty.paneKey] = context + } + } + return Object.keys(contexts).length > 0 ? contexts : undefined + } + + private getAgentStatusOrchestrationContextForHandle( + handle: string, + db = this.getOrchestrationDbIfAvailable() + ): AgentStatusOrchestrationContext | undefined { + // Why: active dispatch is authoritative for reused terminals; settled context stale-groups later work once its row is gone. + const dispatch = + db?.getActiveDispatchForTerminal?.(handle) ?? + this.getRecentSettledDispatchForTerminal(handle, db) + if (!dispatch) { + return undefined + } + const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) + const display = + typeof task?.spec === 'string' + ? buildOrchestrationTaskDisplayMetadata({ + spec: task.spec, + taskTitle: task.task_title, + displayName: task.display_name + }) + : { taskTitle: '', displayName: '' } + const owningRun = + task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined + const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined + const legacyActiveRun = + owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') + ? db?.getActiveCoordinatorRun?.() + : undefined + // Why: legacy coordinator runs have no durable task ownership, so fail closed across worktrees. + const handleWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(handle) : null + const legacyCoordinatorWorktreeId = legacyActiveRun + ? this.getWorktreeIdForTerminalHandle(legacyActiveRun.coordinator_handle) + : null + const scopedLegacyActiveRun = + legacyActiveRun && + handleWorktreeId && + legacyCoordinatorWorktreeId && + worktreeIdsEqual(legacyCoordinatorWorktreeId, handleWorktreeId) + ? legacyActiveRun + : undefined + const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyActiveRun?.coordinator_handle + const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyActiveRun?.id + const creatorPaneKey = task?.created_by_pane_key + const creatorPaneHandle = creatorPaneKey + ? this.getTerminalHandleForPaneKey(creatorPaneKey) + : null + const creatorAuthority = creatorPaneHandle + ? this.getOrchestrationDispatchAuthority(creatorPaneHandle) + : null + const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null + const currentCreatorPane = creatorAuthority?.paneKey + ? parsePaneKey(creatorAuthority.paneKey) + : null + const sameCreatorPane = Boolean( + creatorPaneKey && + creatorAuthority?.paneKey && + (creatorPaneKey === creatorAuthority.paneKey || + (storedCreatorPane && + currentCreatorPane && + storedCreatorPane.leafId === currentCreatorPane.leafId)) + ) + const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined + const sameRunCreatorDispatch = Boolean( + task?.creator_dispatch_id && + task.creator_dispatch_run_id === owningRun?.id && + task.creator_dispatch_pane_key && + task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && + parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId + ) + const currentCreatorHandle = + owningRun?.legacy === 0 && + task?.created_by_run_generation === owningRun.consumer_generation && + task.created_by_process_incarnation === creatorAuthority?.processIncarnation && + sameCreatorPane && + (paneRun + ? paneRun.id === owningRun.id && + paneRun.consumer_generation === task.created_by_run_generation + : sameRunCreatorDispatch) + ? (creatorPaneHandle ?? undefined) + : undefined + const parentTerminalHandle = + currentCreatorHandle ?? + (coordinatorHandle && coordinatorHandle !== handle ? coordinatorHandle : undefined) + const parentPaneKey = parentTerminalHandle + ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) + : undefined + + return { + taskId: dispatch.task_id, + dispatchId: dispatch.id, + dispatchStatus: dispatch.status, + ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), + ...(display.displayName ? { displayName: display.displayName } : {}), + ...(parentTerminalHandle ? { parentTerminalHandle } : {}), + ...(parentPaneKey ? { parentPaneKey } : {}), + ...(coordinatorHandle ? { coordinatorHandle } : {}), + ...(orchestrationRunId ? { orchestrationRunId } : {}) + } + } + + private getRecentSettledDispatchForTerminal( + handle: string, + db = this.getOrchestrationDbIfAvailable() + ): ReturnType { + const dispatch = db?.getLatestDispatchForTerminal?.(handle) + if ( + !dispatch?.completed_at || + dispatch.status === 'pending' || + dispatch.status === 'dispatched' + ) { + return undefined + } + const completedAtMs = Date.parse( + dispatch.completed_at.includes('T') + ? dispatch.completed_at + : `${dispatch.completed_at.replace(' ', 'T')}Z` + ) + if (!Number.isFinite(completedAtMs)) { + return undefined + } + return Date.now() - completedAtMs <= AGENT_STATUS_STALE_AFTER_MS ? dispatch : undefined + } + + // Why: public because automation completion watching runs in main but the + // pane→handle mapping is runtime-owned state. + getTerminalHandleForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined + if (leaf?.ptyId && leaf.connected) { + return this.issueHandle(leaf) + } + const panePty = this.getPtyRecordForPaneKey(paneKey) + if (panePty?.connected) { + return this.issuePtyHandle(panePty) + } + if (leaf?.ptyId) { + return this.issueHandle(leaf) + } + return panePty ? this.issuePtyHandle(panePty) : null + } + + private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + let leafPty: RuntimePtyWorktreeRecord | null = null + if (parsed) { + const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) + const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + if (pty?.connected) { + return pty + } + leafPty = pty ?? null + for (const candidate of this.leaves.values()) { + if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { + continue + } + const remintedPty = this.ptysById.get(candidate.ptyId) + if (remintedPty?.connected) { + return remintedPty + } + leafPty ??= remintedPty ?? null + } + } + let newestMatch: RuntimePtyWorktreeRecord | null = null + for (const pty of this.ptysById.values()) { + const ptyPane = parsePaneKey(pty.paneKey ?? '') + if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { + if (pty.connected) { + return pty + } + newestMatch = pty + } + } + return leafPty ?? newestMatch + } + + private getPaneKeyForTerminalHandle(handle: string): string | null { + const livePty = this.getLivePtyForHandle(handle) + if (livePty?.pty.paneKey) { + return livePty.pty.paneKey + } + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + return null + } + if (!isTerminalLeafId(record.leafId)) { + return null + } + return makePaneKey(record.tabId, record.leafId) + } + + private getWorktreeIdForTerminalHandle(handle: string): string | null { + const livePty = this.getLivePtyForHandle(handle) + if (livePty?.pty.worktreeId) { + return livePty.pty.worktreeId + } + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + return null + } + return record.worktreeId + } + + private setPtyManagementTitleFromObservedTitle( + pty: RuntimePtyWorktreeRecord, + title: string | null | undefined, + observedAt: number + ): void { + const trimmed = title?.trim() + if (!trimmed) { + return + } + if (isClaudeManagementTitle(trimmed)) { + pty.managementTitle = trimmed + pty.managementTitleAt = observedAt + return + } + if ( + detectAgentStatusFromTitle(trimmed) !== null && + observedAt >= (pty.managementTitleAt ?? -1) + ) { + pty.managementTitle = null + pty.managementTitleAt = null + } + } + + private nextTitleObservationSequence(): number { + this.titleObservationSequence += 1 + return this.titleObservationSequence + } + + // Why: title is the tightest agent-presence signal, but a Claude management title is negative evidence for task activity. + async isTerminalRunningAgent( + handle: string, + options: { retryForegroundWrappers?: boolean } = {} + ): Promise { + try { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) + return await this.isPtyRunningAgent(pty.pty, leaf, options) + } + const { leaf } = this.getLiveLeafForHandle(handle) + const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null + // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). + const paneTitle = getLatestLeafTitle(leaf, null) + const paneTitleClassification = classifyAgentTitle(paneTitle) + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification) + : agentTitleProvesAgentPresence(paneTitle, paneTitleClassification) + ) { + return true + } + const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null + const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification) + : agentTitleProvesAgentPresence(tabTitle, tabTitleClassification) + ) { + return true + } + const openCodeMarkerTitle = paneTitle ?? tabTitle + const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { + return true + } + const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null + if (leaf.lastAgentStatus !== null && !hasCurrentTitleEvidence) { + return true + } + if (!leaf.ptyId || !this.ptyController) { + return false + } + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + // Why: a bare `Cursor Agent` title is identity, not liveness — it reads the same + // whether cursor-agent is parked or long exited with the shell back. A null + // foreground is untracked, not alive, so no-evidence must stay a refusal. A live + // pane wrongly refused here means the read failed; fix that, not this. + if (!fg) { + return false + } + // Why: Claude's management UI runs under the Claude process but isn't a task-capable session; suppress only that process. + const shouldSuppressClaudeForeground = + paneTitleClassification === 'management' || tabTitleClassification === 'management' + if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { + return false + } + // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). + return await this.isRecognizedForegroundAgentProcess(leaf.ptyId, fg, { + suppressClaude: shouldSuppressClaudeForeground, + retryWrappers: options.retryForegroundWrappers !== false + }) + } catch { + return false + } + } + + async isTerminalRunningSettledPromptAgent(handle: string): Promise { + try { + const livePty = this.getLivePtyForHandle(handle) + const leaf = livePty ? null : this.getLiveLeafForHandle(handle).leaf + const ptyId = livePty?.pty.ptyId ?? leaf?.ptyId ?? null + const trackedPty = livePty?.pty ?? (ptyId ? this.ptysById.get(ptyId) : null) + if (!ptyId || !trackedPty || !this.ptyController) { + return false + } + const recognized = recognizeAgentProcess(await this.ptyController.getForegroundProcess(ptyId)) + const recognizedAgent = recognized?.agent + if (!isTerminalSendSettlementAgent(recognizedAgent)) { + return false + } + if (!(await this.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }))) { + return false + } + trackedPty.foregroundAgent = recognizedAgent + return true + } catch { + return false + } + } + + private async isPtyRunningAgent( + pty: RuntimePtyWorktreeRecord, + leaf: RuntimeLeafRecord | null = null, + options: { retryForegroundWrappers?: boolean } = {} + ): Promise { + const leafTitle = leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const leafTitleClassification = classifyAgentTitle(leafTitle) + if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) { + return true + } + const ptyTitle = getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + const ptyTitleClassification = classifyAgentTitle(ptyTitle) + if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) { + return true + } + const managementTitleClassification = classifyLatestAgentTitle({ + title: pty.managementTitle, + updatedAt: pty.managementTitleAt + }) + const openCodeMarkerTitle = leafTitle ?? ptyTitle + if (isOpenCodeNativeTitle(openCodeMarkerTitle) && pty.launchAgent === 'opencode') { + return true + } + const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { + return true + } + // Why: stale status is a fallback only when no current title evidence exists; neutral titles (shells) clear it. + if ( + pty.lastAgentStatus !== null && + leafTitle === null && + ptyTitle === null && + managementTitleClassification !== 'management' + ) { + return true + } + if (!this.ptyController) { + return false + } + const fg = await this.ptyController.getForegroundProcess(pty.ptyId) + // Why: mirrors the leaf path — an unreadable foreground is indistinguishable from an + // exited one, so a bare Cursor identity title never substitutes for corroboration. + if (!fg) { + return false + } + const shouldSuppressClaudeForeground = + leafTitle !== null + ? leafTitleClassification === 'management' + : managementTitleClassification === 'management' + if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { + return false + } + // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). + return await this.isRecognizedForegroundAgentProcess(pty.ptyId, fg, { + suppressClaude: shouldSuppressClaudeForeground, + retryWrappers: options.retryForegroundWrappers !== false + }) + } + + private async isRecognizedForegroundAgentProcess( + ptyId: string, + foregroundProcess: string, + options: { suppressClaude?: boolean; retryWrappers?: boolean } = {} + ): Promise { + const initialRecognition = recognizeAgentProcess(foregroundProcess) + if (initialRecognition !== null) { + return !( + options.suppressClaude === true && + isExpectedAgentProcess(initialRecognition.processName, 'claude') + ) + } + if ( + options.retryWrappers === false || + !this.isAgentWrapperForegroundProcess(foregroundProcess) || + !this.ptyController + ) { + return false + } + const startedAt = Date.now() + while (Date.now() - startedAt < FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS) { + await new Promise((resolve) => + setTimeout(resolve, FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS) + ) + const refreshedProcess = await this.ptyController.getForegroundProcess(ptyId) + const refreshedRecognition = recognizeAgentProcess(refreshedProcess) + if (refreshedRecognition !== null) { + return !( + options.suppressClaude === true && + isExpectedAgentProcess(refreshedRecognition.processName, 'claude') + ) + } + if (!refreshedProcess || !this.isAgentWrapperForegroundProcess(refreshedProcess)) { + return false + } + } + return false + } + + private isAgentWrapperForegroundProcess(processName: string): boolean { + // Why: daemon/SSH PTYs can report the interpreter before the async cmdline cache resolves; retry only known wrappers. + return isAgentForegroundWrapperProcess(processName) + } + + private getPrimaryLeafForPty(ptyId: string): RuntimeLeafRecord | null { + return this.getLeavesForPty(ptyId)[0] ?? null + } + + deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { + this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes) + } + + /** Admission snapshot taken when a mailbox pointer's text lands, asserted again + * before its Enter. The two writes straddle a 500ms pause, so a structured + * session that re-leases the pty in between must not receive the submit. */ + private readonly orchestrationPointerAdmissionByPtyId = new Map< + string, + AgentSessionPtyWriteAdmittance + >() + + private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise { + try { + if (data === '\r') { + const admitted = this.orchestrationPointerAdmissionByPtyId.get(ptyId) + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + if (admitted) { + // Throws when the lease moved under the in-flight pointer, withholding the submit. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + } else { + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (!admission.admitted) { + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + // Preserve the controller's own refusal reporting for internal deliveries. + return this.ptyController?.write(ptyId, data) ?? false + } + this.orchestrationPointerAdmissionByPtyId.set(ptyId, { + sessionId: admission.sessionId, + runtimeFence: admission.runtimeFence + }) + } + if (this.ptyController?.writeWithSettlement) { + return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false) + } + return this.ptyController?.write(ptyId, data) ?? false + } catch { + return false + } + } + + private retireOrchestrationMailboxDeliveryForPty(ptyId: string): void { + this.orchestrationMailboxNotifications.retirePty(ptyId) + for (const leaf of this.getLeavesForPty(ptyId)) { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + this.mailPointerRepointScheduler.schedule(handle) + } + const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.mailPointerRepointScheduler.schedule(`run:${run.id}`) + } + } + } + + private scheduleRestoredMessageRepoints(): void { + let handles: string[] + try { + handles = this._orchestrationDb?.getUndeliveredUnreadMailboxHandles?.() ?? [] + } catch (error) { + console.warn('[orchestration] failed to scan restored mailboxes', error) + return + } + for (const handle of handles) { + try { + if (handle.startsWith('dispatch:')) { + continue + } + if (handle.startsWith('run:')) { + this.mailPointerRepointScheduler.schedule(handle) + continue + } + const routed = this.orchestrationMailboxOwner.routeDetachedDirectMessages(handle) + for (const mailbox of routed.mailboxes) { + this.mailPointerRepointScheduler.schedule(mailbox.mailboxHandle) + } + if (!routed.hasMore) { + this.mailPointerRepointScheduler.schedule(handle) + } + } catch (error) { + console.warn(`[orchestration] failed to restore mailbox ${handle}`, error) + this.mailPointerRepointScheduler.schedule(handle) + } + } + } + + private repointPendingMessagesForHandle(handle: string): void { + try { + this.deliverPendingMessagesForHandle(handle) + } catch { + // The unref'd repair can outlive a test/runtime-owned database during shutdown. + } + } + + private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void { + this.orchestrationMailboxNotifications.deliverForLeaf(leaf) + } + + // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. + notifyMessageArrived(handle: string, messageType?: string): void { + if (!handle.startsWith('dispatch:')) { + this.mailPointerRepointScheduler.schedule(handle) + } + this.orchestrationMailboxNotifications.notifyMessageArrived(handle, messageType) + } + + waitForMessage( + handle: string, + options?: { + typeFilter?: string[] + timeoutMs?: number + signal?: AbortSignal + exclusive?: boolean + } + ): Promise { + return new Promise((resolve) => { + const currentWaiters = this.messageWaitersByHandle.get(handle) + if (options?.exclusive && currentWaiters && currentWaiters.size > 0) { + resolve('waiter_exists') + return + } + const timeoutMs = options?.timeoutMs ?? ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS + + const waiter: MessageWaiter = { + handle, + typeFilter: options?.typeFilter, + resolve, + timeout: null, + abortCleanup: null + } + + // Why: on caller abort (RPC socket closed — design doc §3.1), resolve now to release the long-poll slot instead of waiting out timeoutMs. + const signal = options?.signal + const onAbort = (): void => { + this.removeMessageWaiter(waiter) + resolve('cancelled') + } + if (signal) { + if (signal.aborted) { + resolve('cancelled') + return + } + waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + } + + waiter.timeout = setTimeout(() => { + this.removeMessageWaiter(waiter) + resolve('timed_out') + }, timeoutMs) + + let waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.messageWaitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + }) + } + + cancelMessageWaiters(handle: string): void { + const waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + return + } + for (const waiter of [...waiters]) { + this.resolveMessageWaiter(waiter, 'cancelled') + } + } + + private resolveMessageWaiter(waiter: MessageWaiter, result: MessageWaitResult): void { + this.removeMessageWaiter(waiter) + waiter.resolve(result) + } + + private removeMessageWaiter(waiter: MessageWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + waiter.timeout = null + } + if (waiter.abortCleanup) { + waiter.abortCleanup() + waiter.abortCleanup = null + } + const waiters = this.messageWaitersByHandle.get(waiter.handle) + if (waiters) { + waiters.delete(waiter) + if (waiters.size === 0) { + this.messageWaitersByHandle.delete(waiter.handle) + } + } + } + + private buildPtyTerminalSummary( + pty: RuntimePtyWorktreeRecord, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(pty.worktreeId) + const title = getLatestPtyTitle(pty) + + const pane = parsePaneKey(pty.paneKey ?? '') + const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId + return { + handle: this.issuePtyHandle(pty), + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + orphaned, + worktreeId: pty.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!, + leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId, + title, + connected: pty.connected, + writable: pty.connected, + lastOutputAt: pty.lastOutputAt, + preview: pty.preview, + ...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}), + ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty.launchAgent, + pty.foregroundAgent, + title, + pty.paneKey ?? null + ) + } + } + + private getLiveLeafForHandle(handle: string): { + record: TerminalHandleRecord + leaf: RuntimeLeafRecord + } { + this.assertGraphReady() + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + throw new Error('terminal_handle_stale') + } + if (record.rendererGraphEpoch !== this.rendererGraphEpoch) { + throw new Error('terminal_handle_stale') + } + + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) { + throw new Error('terminal_handle_stale') + } + return { record, leaf } + } + + private getLivePtyForHandle(handle: string): { + record: TerminalHandleRecord + pty: RuntimePtyWorktreeRecord + } | null { + let record = this.handles.get(handle) + if (!record) { + const ptyId = [...this.handleByPtyId.entries()].find( + ([, mappedHandle]) => mappedHandle === handle + )?.[0] + const pty = ptyId ? this.ptysById.get(ptyId) : null + if (pty) { + // Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity. + this.issuePtyHandle(pty) + record = this.handles.get(handle) + } + } + if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { + return null + } + if (!record.ptyId) { + return null + } + const pty = this.ptysById.get(record.ptyId) + if (!pty || pty.ptyId !== record.ptyId) { + return null + } + // Why: renderer adoption can race with CLI reads; keep ptyId → handle populated so summaries don't mint a second handle for the same terminal. + this.handleByPtyId.set(record.ptyId, handle) + return { record, pty } + } + + private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void { + const runtimePty = this.getLivePtyForHandle(handle) + if (runtimePty) { + if (runtimePty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_handle_stale') + } + return + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_handle_stale') + } + } + + private readPtyTerminal( + handle: string, + pty: RuntimePtyWorktreeRecord, + opts: { cursor?: number; limit?: number } = {} + ): RuntimeTerminalRead { + return readTerminalTail({ + handle, + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', + previewLines: pty.tailBuffer, + completedLines: pty.tailTranscriptBuffer, + partialLine: pty.tailPartialLine, + completedLineCount: pty.tailLinesTotal, + bufferTruncated: pty.tailTruncated, + cursor: opts.cursor, + limit: opts.limit + }) + } + + private issueHandle(leaf: RuntimeLeafRecord): string { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const existingHandle = this.handleByLeafKey.get(leafKey) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && + existingRecord.ptyId === leaf.ptyId && + existingRecord.ptyGeneration === leaf.ptyGeneration + ) { + return existingHandle + } + } + + const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf) + if (preAllocatedHandle) { + return preAllocatedHandle + } + const incarnationId = leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null + const retained = leaf.ptyId ? this.handleByPtyIncarnation.get(leaf.ptyId) : undefined + if (retained && leaf.ptyId && retained.incarnationId !== incarnationId) { + this.invalidatePtyIncarnationHandle(leaf.ptyId) + } else if (retained) { + this.bindPtyIncarnationHandle(retained, leaf) + return retained.handle + } + + const handle = `term_${randomUUID()}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, handle) + if (leaf.ptyId && incarnationId) { + this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey }) + } + return handle + } + + private bindPtyIncarnationHandle( + retained: PtyIncarnationHandleRecord, + leaf: RuntimeLeafRecord + ): void { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + if (retained.leafKey !== leafKey) { + if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) { + this.handleByLeafKey.delete(retained.leafKey) + } + retained.leafKey = leafKey + } + this.handles.set(retained.handle, { + handle: retained.handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, retained.handle) + } + + private invalidatePtyIncarnationHandle(ptyId: string): void { + const retained = this.handleByPtyIncarnation.get(ptyId) + if (!retained) { + return + } + this.handleByPtyIncarnation.delete(ptyId) + if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) { + this.handleByLeafKey.delete(retained.leafKey) + } + this.handles.delete(retained.handle) + this.syntheticTerminalHandles.delete(retained.handle) + this.rejectWaitersForHandle(retained.handle, 'terminal_handle_stale') + } + + private clearPtyIncarnationHandles(): void { + for (const retained of this.handleByPtyIncarnation.values()) { + this.syntheticTerminalHandles.delete(retained.handle) + } + this.handleByPtyIncarnation.clear() + } + + private reconcilePtyIncarnationHandles(): void { + for (const [ptyId, retained] of this.handleByPtyIncarnation) { + const pty = this.ptysById.get(ptyId) + const leaves = this.getLeavesForPty(ptyId) + if ( + !pty?.incarnationId || + pty.incarnationId !== retained.incarnationId || + leaves.length !== 1 || + this.handleByPtyId.has(ptyId) + ) { + this.invalidatePtyIncarnationHandle(ptyId) + continue + } + this.bindPtyIncarnationHandle(retained, leaves[0]) + } + } + + private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null { + if (!leaf.ptyId) { + return null + } + const preAllocated = this.handleByPtyId.get(leaf.ptyId) + if (!preAllocated) { + return null + } + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + this.handles.set(preAllocated, { + handle: preAllocated, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, preAllocated) + return preAllocated + } + + private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = + this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.runtimeId === this.runtimeId && + existingRecord.ptyId === pty.ptyId + ) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + } + + const handle = existingHandle ?? `term_${randomUUID()}` + if (!existingHandle) { + this.syntheticTerminalHandles.add(handle) + } + const syntheticId = `pty:${pty.ptyId}` + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private findHandleForPtyRecord(ptyId: string): string | null { + for (const [handle, record] of this.handles) { + if ( + record.runtimeId === this.runtimeId && + record.ptyId === ptyId && + record.tabId.startsWith('pty:') + ) { + return handle + } + } + return null + } + + private refreshWritableFlags(): void { + for (const leaf of this.leaves.values()) { + leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null + } + } + + private invalidateLeafHandle(leafKey: string): void { + const handle = this.handleByLeafKey.get(leafKey) + if (!handle) { + return + } + const record = this.handles.get(handle) + if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) { + this.handleByPtyIncarnation.delete(record.ptyId) + } + this.handleByLeafKey.delete(leafKey) + this.handles.delete(handle) + this.syntheticTerminalHandles.delete(handle) + this.rejectWaitersForHandle(handle, 'terminal_handle_stale') + } + + private adoptFirstPtyForLeafHandle( + leafKey: string, + ptyId: string | null, + ptyGeneration: number + ): boolean { + const handle = this.handleByLeafKey.get(leafKey) + const record = handle ? this.handles.get(handle) : null + if (!handle || !record || record.ptyId !== null || ptyId === null) { + return false + } + this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + return true + } + + private rememberDetachedPreAllocatedLeaves(): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { + // Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control survives renderer graph loss while the PTY is alive. + this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf) + } + } + } + + private resolveExitWaiters(leaf: RuntimeLeafRecord): void { + const handle = this.issueHandle(leaf) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'exit') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'exit', leaf)) + } else { + // Why: after exit, conditions like tui-idle can never be satisfied — reject now instead of spinning the poll until timeout on a dead process. + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_exited')) + } + } + } + + private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const candidateHandle = + this.handleByLeafKey.get(leafKey) ?? + (leaf.ptyId + ? (this.handleByPtyId.get(leaf.ptyId) ?? + this.handleByPtyIncarnation.get(leaf.ptyId)?.handle) + : undefined) + if (!candidateHandle || !this.waitersByHandle.has(candidateHandle)) { + return + } + const handle = this.issueHandle(leaf) + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'tui-idle') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) + } + } + } + + private resolvePtyExitWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'exit') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'exit', pty)) + } else { + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_exited')) + } + } + } + + private isPtyKnownExited(ptyId: string): boolean { + const pty = this.ptysById.get(ptyId) + if (pty) { + // Why: `!connected` is an inference, not proof. The liveness sweep clears it with no + // exit code for every PTY of a dropped relay, so reading that as an exit retires the + // lease of a process still running on the host — 'unknown' must keep watching. + return getPtyTerminalState(pty) === 'exited' + } + return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited') + } + + private notifyPtyExitListeners(ptyId: string): void { + const listeners = this.ptyExitListenersByPtyId.get(ptyId) + if (!listeners) { + return + } + this.ptyExitListenersByPtyId.delete(ptyId) + notifyRuntimeListeners(listeners, (listener) => listener(), 'pty-exit') + } + + private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'tui-idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) + } + } + } + + // Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires. + private startTuiIdleFallbackPoll( + waiter: TerminalWaiter, + leaf: RuntimeLeafRecord, + waiterTimeoutMs: number + ): void { + let foregroundPollInFlight = false + waiter.pollInterval = setInterval(async () => { + if (!waiter.pollInterval) { + return + } + let startedForegroundPoll = false + try { + if (leaf.lastAgentStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } + // Why: the renderer-synced title is the only path where OSC titles are visible for daemon-hosted terminals. + const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (pollTitle) { + const titleStatus = detectExplicitIdleStatusFromTitle(pollTitle) + if (titleStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } + } + const leafWaitText = buildTerminalWaitText( + leaf.tailBuffer, + leaf.tailPartialLine, + leaf.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(leafWaitText) + if (blockedReason) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter( + waiter, + buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason) + ) + return + } + if (isKnownReadyPromptPreview(leafWaitText)) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } + // Foreground fallback: a reported non-shell process with quiet output is treated as idle. + if ( + leaf.lastAgentStatus === null && + leaf.ptyId && + this.ptyController && + !foregroundPollInFlight + ) { + foregroundPollInFlight = true + startedForegroundPoll = true + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + if (fg && !isShellProcess(fg)) { + const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 + if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + } + } + } + } catch { + // Swallow transient PTY inspection errors and keep polling. + } finally { + if (startedForegroundPoll) { + foregroundPollInFlight = false + } + } + }, TUI_IDLE_POLL_INTERVAL_MS) + const retainedWaitText = buildTerminalWaitText( + leaf.tailBuffer, + leaf.tailPartialLine, + leaf.preview + ) + if (leaf.lastAgentStatus === null && retainedWaitText.length === 0) { + this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) + } + } + + private startPtyTuiIdleFallbackPoll( + waiter: TerminalWaiter, + pty: RuntimePtyWorktreeRecord, + waiterTimeoutMs: number + ): void { + let foregroundPollInFlight = false + waiter.pollInterval = setInterval(async () => { + if (!waiter.pollInterval) { + return + } + let startedForegroundPoll = false + try { + if (pty.lastAgentStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + return + } + const ptyWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + const blockedReason = detectTerminalWaitBlockedReason(ptyWaitText) + if (blockedReason) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter( + waiter, + buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason) + ) + return + } + // Why: adopted background PTY handles use their live xterm title as the same readiness signal as leaf handles. + if ( + this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || + isKnownReadyPromptPreview(ptyWaitText) + ) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + return + } + if (pty.lastAgentStatus === null && this.ptyController && !foregroundPollInFlight) { + foregroundPollInFlight = true + startedForegroundPoll = true + const fg = await this.ptyController.getForegroundProcess(pty.ptyId) + if (fg && !isShellProcess(fg)) { + const quietMs = pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0 + if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + } + } + } + } catch { + // Swallow transient PTY inspection errors and keep polling. + } finally { + if (startedForegroundPoll) { + foregroundPollInFlight = false + } + } + }, TUI_IDLE_POLL_INTERVAL_MS) + const retainedWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + if (pty.lastAgentStatus === null && retainedWaitText.length === 0) { + this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) + } + } + + /** One bounded look at the provider's screen for an adopted PTY whose retained + * readiness metadata was lost. Deliberately single-shot: it answers "is the + * screen already showing a settled prompt", and the poll above owns every + * later transition. A provider screen that is still working when this fires + * resolves through the poll, not here. */ + private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void { + const settleMarginMs = Math.min( + TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS, + Math.max(1, Math.floor(waiterTimeoutMs / 3)) + ) + const probeTimeoutMs = Math.min( + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS + settleMarginMs, + Math.max(0, waiterTimeoutMs - settleMarginMs) + ) + const providerTimeoutMs = Math.min( + VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + Math.max(0, probeTimeoutMs - settleMarginMs) + ) + // Node clamps sub-millisecond timers to 1ms, so no distinct retirement deadline exists. + if (providerTimeoutMs < 1) { + return + } + // Retire the provider before the detached probe and waiter can settle. + void withTimeout( + this.readTerminal( + waiter.handle, + {}, + { + timeoutMs: providerTimeoutMs, + retireOnTimeout: true, + // Why: the ready banner stays in scrollback for the whole session, so + // classifying history would call a working agent idle (#15569 review). + visibleScreenOnly: true + } + ), + probeTimeoutMs, + null + ) + .then((read) => { + if ( + !read || + read.source !== 'screen' || + !this.waitersByHandle.get(waiter.handle)?.has(waiter) + ) { + return + } + const snapshotText = read.tail.join('\n') + const blockedReason = detectTerminalWaitBlockedReason(snapshotText) + if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) { + return + } + // Why resolve before clearing: a stale handle throws while locating the + // record, and a cleared interval would leave the waiter with no poll and + // no probe — able to end only in timeout. + const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason) + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, result) + }) + .catch(() => {}) + } + + private buildTuiIdleProbeResult( + handle: string, + blockedReason: RuntimeTerminalWaitBlockedReason | null + ): RuntimeTerminalWait { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return blockedReason + ? buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason) + : buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty) + } + const { leaf } = this.getLiveLeafForHandle(handle) + return blockedReason + ? buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason) + : buildTerminalWaitResult(handle, 'tui-idle', leaf) + } + + private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId !== pty.ptyId) { + continue + } + const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (!title) { + continue + } + const status = detectExplicitIdleStatusFromTitle(title) + if (status !== null) { + return status + } + } + return null + } + + private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { + this.removeWaiter(waiter) + waiter.resolve(result) + } + + private bindTerminalWaiterAbort( + waiter: TerminalWaiter, + signal: AbortSignal | undefined + ): boolean { + if (!signal) { + return true + } + if (signal.aborted) { + return false + } + const onAbort = (): void => { + this.removeWaiter(waiter) + waiter.reject(new Error('request_aborted')) + } + waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + return true + } + + private rejectWaitersForHandle(handle: string, code: string): void { + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + this.removeWaiter(waiter) + waiter.reject(new Error(code)) + } + } + + private rejectAllWaiters(code: string): void { + for (const handle of [...this.waitersByHandle.keys()]) { + this.rejectWaitersForHandle(handle, code) + } + } + + private removeWaiter(waiter: TerminalWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + } + if (waiter.abortCleanup) { + waiter.abortCleanup() + waiter.abortCleanup = null + } + const waiters = this.waitersByHandle.get(waiter.handle) + if (!waiters) { + return + } + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByHandle.delete(waiter.handle) + } + } + + private getLeafKey(tabId: string, leafId: string): string { + return `${tabId}::${leafId}` + } + + // ── Linear integration ── + + linearConnect(apiKey: string): ReturnType { + return connectLinear(apiKey) + } + + linearDisconnect(workspaceId?: string): { ok: true } { + disconnectLinear(workspaceId) + return { ok: true } + } + + linearSelectWorkspace(workspaceId: LinearWorkspaceSelection): ReturnType { + return selectLinearWorkspace(workspaceId) + } + + linearStatus(): ReturnType { + return getLinearStatus() + } + + linearTestConnection(workspaceId?: string): ReturnType { + return testLinearConnection(workspaceId) + } + + linearSearchIssues( + query: string, + limit = 20, + workspaceId?: LinearWorkspaceSelection + ): ReturnType { + return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId) + } + + linearSearchForAgents(args: { + query: string + limit?: number + workspaceId?: (string & {}) | 'all' + }): ReturnType { + return searchLinearIssuesForAgents(args) + } + + linearIssueContext(request: LinearIssueRequest): ReturnType { + return readLinearIssueContext(request, (context) => this.linearResolveCurrentIssue(context)) + } + + async linearTeamListForAgents(params: { + workspaceId?: (string & {}) | 'all' + }): Promise { + try { + const result = await listLinearTeamsForAgent(params.workspaceId) + const workspaceErrors = result.errors.map((error) => ({ + workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, + code: this.linearWorkspaceErrorCode(error.type), + message: sanitizeLinearErrorMessage(error.message) + })) + return { + teams: result.teams.map((team) => this.linearTeamSummary(team)), + meta: { + workspaceId: params.workspaceId, + returned: result.teams.length, + partial: workspaceErrors.length > 0, + workspaceErrors + } + } + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearTeamMembersForAgents(params: { + teamInput: string + workspaceId?: string + }): Promise { + const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) + try { + const members = await getLinearTeamMembersOrThrow(team.id, team.workspaceId) + return { + team: this.linearTeamSummary(team), + members: members.map((member) => ({ + id: member.id, + displayName: member.displayName, + avatarUrl: member.avatarUrl + })), + meta: { workspaceId: team.workspaceId, returned: members.length } + } + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearTeamStatesForAgents(params: { + teamInput: string + workspaceId?: string + }): Promise { + const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) + const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) + return { + team: this.linearTeamSummary(team), + states: states.map((state) => ({ + id: state.id, + name: state.name, + type: state.type, + color: state.color, + position: state.position + })), + meta: { workspaceId: team.workspaceId, returned: states.length } + } + } + + async linearTeamLabelsForAgents(params: { + teamInput: string + workspaceId?: string + }): Promise { + const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) + const labels = await this.getLinearTeamLabelsForWrite(team.id, team.workspaceId) + return { + team: this.linearTeamSummary(team), + labels: labels.map((label) => ({ id: label.id, name: label.name, color: label.color })), + meta: { workspaceId: team.workspaceId, returned: labels.length } + } + } + + async linearProjectListForAgents(params: { + query?: string + limit?: number + workspaceId?: (string & {}) | 'all' + }): Promise { + const limit = clampLinearSearchLimit(params.limit) + try { + const result = await this.linearListProjects(params.query, limit, params.workspaceId, true) + const projects = result.items.slice(0, limit).map((project) => ({ + id: project.id, + name: project.name, + ...(project.url ? { url: project.url } : {}), + ...(project.workspaceId ? { workspaceId: project.workspaceId } : {}), + ...(project.workspaceName ? { workspaceName: project.workspaceName } : {}), + ...(project.teams ? { teams: project.teams } : {}) + })) + const workspaceErrors = (result.errors ?? []).map((error) => ({ + workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, + code: this.linearWorkspaceErrorCode(error.type), + message: sanitizeLinearErrorMessage(error.message) + })) + const hasMore = result.hasMore === true || result.items.length > limit + return { + projects, + truncated: hasMore, + meta: { + query: params.query, + workspaceId: params.workspaceId, + limit, + returned: projects.length, + hasMore, + partial: workspaceErrors.length > 0, + workspaceErrors + } + } + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearIssueListForAgents(params: { + filter?: LinearIssueListFilter + teamInput?: string + limit?: number + workspaceId?: (string & {}) | 'all' + }): Promise { + const filter = params.filter ?? 'assigned' + const limit = clampLinearIssueListLimit(params.limit) + const team = params.teamInput + ? await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) + : null + const workspaceId = team?.workspaceId ?? params.workspaceId + try { + const result = await listLinearIssues(filter, limit, workspaceId, { + teamId: team?.id + }) + return { + issues: result.items.map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state, + team: issue.team, + project: issue.project ?? null, + assignee: issue.assignee ?? null, + priority: issue.priority, + estimate: issue.estimate, + dueDate: issue.dueDate, + updatedAt: issue.updatedAt, + priorityLabel: linearPriorityLabel(issue.priority), + workspace: { + id: issue.workspaceId ?? workspaceId ?? '', + name: issue.workspaceName ?? issue.workspaceId ?? workspaceId ?? '' + } + })), + truncated: result.hasMore === true, + meta: { + filter, + workspaceId, + ...(team ? { team: this.linearTeamSummary(team) } : {}), + limit, + returned: result.items.length, + hasMore: result.hasMore === true, + partial: (result.errors?.length ?? 0) > 0, + workspaceErrors: (result.errors ?? []).map((error) => ({ + workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, + code: this.linearWorkspaceErrorCode(error.type), + message: sanitizeLinearErrorMessage(error.message) + })) + } + } + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearMcpIssueList(params: LinearMcpIssueListRequest): Promise { + try { + return await listMcpIssues(params) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearResolveCurrentIssue( + context?: LinearCurrentIssueContextHints + ): Promise> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + + let worktree: ResolvedWorktree | null = null + if (context?.terminalHandle) { + try { + const terminal = await this.showTerminal(context.terminalHandle) + if (context.worktreeId && context.worktreeId !== terminal.worktreeId) { + throw new LinearAgentAccessError( + 'linear_permission_denied', + 'The provided Linear worktree context does not match the caller terminal.' + ) + } + worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + } catch (error) { + if (error instanceof LinearAgentAccessError) { + throw error + } + if (context.remote === true || context.worktreeId) { + throw new LinearAgentAccessError( + 'linear_issue_required', + 'Could not verify the current Linear-linked worktree.' + ) + } + } + } + + if (!worktree && context?.remote !== true && context?.cwd) { + worktree = await this.resolveWorktreeForContainedPath(context.cwd) + if (!worktree) { + throw new LinearAgentAccessError( + 'linear_issue_required', + 'Run --current from inside an Orca-managed worktree or pass an issue id.' + ) + } + } + + if (!worktree) { + throw new LinearAgentAccessError( + 'linear_issue_required', + 'Run --current from inside an Orca-managed worktree or pass an issue id.' + ) + } + + const link = getLinearCurrentIssueFromWorktree(worktree) + if (!link.workspaceId) { + const backfill = resolveLegacyLinearLinkWorkspace( + worktree.linkedLinearIssue ?? '', + worktree.linkedLinearIssueOrganizationUrlKey + ) + if (backfill?.workspaceId) { + this.store.setWorktreeMeta(worktree.id, { + linkedLinearIssueWorkspaceId: backfill.workspaceId, + linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null + }) + return { + ...link, + workspaceId: backfill.workspaceId, + organizationUrlKey: backfill.organizationUrlKey ?? link.organizationUrlKey, + backfill + } + } + } + return link + } + + private async resolveWorktreeForContainedPath(cwd: string): Promise { + const currentPath = resolve(cwd) + let best: ResolvedWorktree | null = null + for (const candidate of await this.listResolvedWorktrees()) { + if (!isPathInsideOrEqual(candidate.path, currentPath)) { + continue + } + if (!best || candidate.path.length > best.path.length) { + best = candidate + } + } + return best + } + + linearListIssues( + filter?: LinearListFilter, + limit = 20, + workspaceId?: LinearWorkspaceSelection, + options?: LinearIssueListOptions + ): ReturnType { + return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options) + } + + linearCreateIssue( + teamId: string, + title: string, + description?: string, + workspaceId?: string, + parentIssueId?: string, + projectId?: string | null, + options?: { + stateId?: string + priority?: number + estimate?: number | null + dueDate?: string | null + assigneeId?: string | null + labelIds?: string[] + } + ): ReturnType { + return createLinearIssue(teamId, title, description, workspaceId, { + parentId: parentIssueId, + projectId, + ...options + }) + } + + linearGetIssue(id: string, workspaceId?: string): ReturnType { + return getLinearIssue(id, workspaceId) + } + + linearUpdateIssue( + id: string, + updates: LinearIssueUpdate, + workspaceId?: string + ): ReturnType { + return updateLinearIssue(id, updates, workspaceId) + } + + linearAddIssueComment( + issueId: string, + body: string, + workspaceId?: string + ): ReturnType { + return addLinearIssueComment(issueId, body, workspaceId) + } + + async linearIssueSetState(params: { + input?: string + current?: boolean + workspaceId?: string + to: string + context?: LinearCurrentIssueContextHints + }): Promise { + const target = await this.resolveLinearAgentWriteTarget(params) + const teamId = target.issue.team?.id + if (!teamId) { + throw linearError('linear_invalid_state', 'The Linear issue does not have a team.') + } + const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId) + const state = this.resolveLinearAgentState(params.to, states) + if (!state) { + throw linearError( + 'linear_invalid_state', + `No workflow state exactly matched "${params.to}".`, + { + states: states.map(({ id, name, type }) => ({ id, name, type })), + nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`] + } + ) + } + + const previousState = + target.issue.state?.id && target.issue.state.name + ? { id: target.issue.state.id, name: target.issue.state.name } + : null + const alreadyInState = target.issue.state?.id === state.id + if (!alreadyInState) { + await this.runLinearAgentWrite( + async (signal) => { + const updated = await updateLinearIssueForAgent( + target.issue.id, + { stateId: state.id }, + target.workspaceId, + { + signal + } + ) + if (updated.state?.id !== state.id) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Linear state update could not be confirmed.' + ) + } + return updated + }, + (cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the state change, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.` + ], + ...(cause ? { cause } : {}) + } + ) + ) + } + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return { + issue: this.linearWriteIssueRef(target.issue), + state: { id: state.id, name: state.name, type: state.type }, + previousState, + meta: { workspaceId: target.workspaceId, alreadyInState } + } + } + + async linearIssueRelationWrite( + params: LinearIssueRelationWriteRequest + ): Promise { + const target = await this.resolveLinearAgentWriteTarget(params) + const related = await this.resolveLinearAgentWriteTarget({ + input: params.relatedInput, + workspaceId: target.workspaceId, + context: params.context + }) + if (target.issue.id === related.issue.id) { + throw linearError('linear_write_failed', 'An issue cannot be related to itself.') + } + try { + const result = await this.runLinearAgentWrite( + (signal) => + writeIssueRelation({ + issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title }, + relatedIssue: { + ...this.linearWriteIssueRef(related.issue), + title: related.issue.title + }, + relationship: params.relationship, + operation: params.operation, + workspaceId: target.workspaceId, + signal + }), + (cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the relation change, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.` + ], + ...(cause ? { cause } : {}) + } + ) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, [ + target.issue.identifier, + related.issue.identifier + ]) + return result + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + async linearSaveIssue(params: LinearSaveIssueRequest): Promise { + if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { + throw linearError('linear_body_too_large', 'Linear issue body is too large.') + } + if (!params.input && !params.current) { + if (!params.title || !params.team) { + throw linearError( + 'linear_write_failed', + 'Creating with save-issue requires both team and title.' + ) + } + const created = await this.linearIssueCreate({ + title: params.title, + body: params.description, + teamInput: params.team, + state: params.state, + assignee: params.assignee ?? undefined, + priority: params.priority, + estimate: params.estimate ?? undefined, + dueDate: params.dueDate ?? undefined, + labels: params.labels, + projectInput: params.project ?? undefined, + parentInput: params.parentId ?? undefined, + workspaceId: params.workspaceId, + writeId: params.writeId, + context: params.context + }) + return { ...created, meta: { ...created.meta, created: true } } + } + if (params.team !== undefined) { + throw linearError('linear_write_failed', 'Team can only be set when creating an issue.') + } + const target = await this.resolveLinearAgentWriteTarget(params) + const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) + const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId) + if (Object.keys(fields).length === 0) { + throw linearError('linear_write_failed', 'No issue fields were provided to save.') + } + const alreadySet = this.linearSavedIssueMatchesIntent(current, fields) + const updated = alreadySet + ? current + : await this.runLinearAgentWrite( + async (signal) => { + const saved = await updateLinearIssueForAgent( + target.issue.id, + fields, + target.workspaceId, + { signal } + ) + if (!this.linearSavedIssueMatchesIntent(saved, fields)) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Linear issue save could not be confirmed.' + ) + } + return saved + }, + (cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the issue save, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.` + ], + ...(cause ? { cause } : {}) + } + ) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return { + issue: updated, + meta: { + workspaceId: target.workspaceId, + created: false + } + } + } + + async linearIssueUpdateTask( + params: LinearIssueTaskUpdateRequest + ): Promise { + const target = await this.resolveLinearAgentWriteTarget(params) + const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) + const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId) + if (!update) { + throw linearError('linear_write_failed', 'No Linear task field update was requested.') + } + const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update) + if (!alreadySet) { + await this.runLinearAgentWrite( + async (signal) => { + const updated = await updateLinearIssueForAgent( + target.issue.id, + update.fields, + target.workspaceId, + { signal } + ) + if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Linear task field update could not be confirmed.' + ) + } + return updated + }, + (cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the task update, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.` + ], + ...(cause ? { cause } : {}) + } + ) + ) + } + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + const finalRecord = alreadySet + ? current + : await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) + return this.linearTaskUpdateResult( + params.operation, + target.issue, + target.workspaceId, + current, + finalRecord, + alreadySet + ) + } + + async linearIssueAddComment(params: { + input?: string + current?: boolean + workspaceId?: string + body: string + replyTo?: string + writeId?: string + context?: LinearCurrentIssueContextHints + }): Promise { + if (params.body.length > LINEAR_WRITE_BODY_CAP) { + throw linearError('linear_body_too_large', 'Linear comment body is too large.') + } + const target = await this.resolveLinearAgentWriteTarget(params) + const parentId = params.replyTo + ? await this.resolveLinearCommentParentId(target.issue.id, params.replyTo, target.workspaceId) + : null + const writeId = params.writeId ?? randomUUID() + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearCommentWrite( + writeId, + target.issue.id, + parentId, + target.workspaceId, + true + ) + : null + if (existing) { + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearCommentResult(existing, target, params.body.length, writeId, true) + } + + try { + const comment = await this.runLinearAgentWrite( + (signal) => + addLinearIssueCommentForAgent(target.issue.id, params.body, target.workspaceId, { + id: writeId, + parentId, + signal + }), + (cause) => + this.linearCreateStyleUnconfirmed('comment', writeId, target, { + parentId, + bodyRequired: true, + cause + }) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearCommentResult(comment, target, params.body.length, writeId, false) + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const comment = await this.refetchLinearCommentAfterDuplicate( + writeId, + target.issue.id, + parentId, + target.workspaceId, + () => + this.linearCreateStyleUnconfirmed('comment', writeId, target, { + parentId, + bodyRequired: true + }) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearCommentResult(comment, target, params.body.length, writeId, true) + } + throw error + } + } + + async linearIssueAttachLink(params: { + input?: string + current?: boolean + workspaceId?: string + url: string + title?: string + writeId?: string + context?: LinearCurrentIssueContextHints + }): Promise { + const url = this.parseLinearAttachmentUrl(params.url) + const target = await this.resolveLinearAgentWriteTarget(params) + const writeId = params.writeId ?? randomUUID() + const title = params.title?.trim() || this.defaultLinearAttachmentTitle(url) + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearAttachmentWrite( + writeId, + target.issue.id, + target.workspaceId, + true + ) + : null + if (existing) { + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearAttachResult(existing, target, writeId, true) + } + try { + const attachment = await this.runLinearAgentWrite( + (signal) => + createLinearIssueAttachment( + target.issue.id, + { id: writeId, title, url: url.toString() }, + target.workspaceId, + { signal } + ), + (cause) => + this.linearCreateStyleUnconfirmed('attach', writeId, target, { + title, + url: url.toString(), + cause + }) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearAttachResult(attachment, target, writeId, false) + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const attachment = await this.refetchLinearAttachmentAfterDuplicate( + writeId, + target.issue.id, + target.workspaceId, + () => + this.linearCreateStyleUnconfirmed('attach', writeId, target, { + title, + url: url.toString() + }) + ) + await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearAttachResult(attachment, target, writeId, true) + } + throw error + } + } + + async linearIssueCreate(params: { + title: string + body?: string + teamInput?: string + teamKey?: string + state?: string + assignee?: string + priority?: number + estimate?: number + dueDate?: string + labels?: string[] + projectInput?: string + parentInput?: string + parentCurrent?: boolean + workspaceId?: string + writeId?: string + context?: LinearCurrentIssueContextHints + }): Promise { + if ((params.body?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { + throw linearError('linear_body_too_large', 'Linear issue body is too large.') + } + const parent = + params.parentInput || params.parentCurrent + ? await this.resolveLinearAgentWriteTarget({ + input: params.parentInput, + current: params.parentCurrent, + workspaceId: params.workspaceId, + context: params.context + }) + : null + if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) { + throw linearError( + 'linear_invalid_workspace', + 'The parent issue belongs to a different workspace.' + ) + } + const team = await this.resolveLinearCreateTeam( + params.teamInput ?? params.teamKey, + params.workspaceId, + parent + ) + const createFields = await this.resolveLinearCreateFields(params, team) + const parentId = parent?.issue.id ?? null + const writeId = params.writeId ?? randomUUID() + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearCreatedIssue( + writeId, + team.id, + parentId, + team.workspaceId, + true, + createFields + ) + : null + if (existing) { + if (parent) { + await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) + } + return this.linearCreateResult(existing, team.workspaceId, writeId, true) + } + + try { + const issue = await this.runLinearAgentWrite( + async (signal) => { + const created = await createLinearIssueForAgent( + team.id, + params.title, + params.body, + team.workspaceId, + { + id: writeId, + parentId, + ...createFields, + signal + } + ) + if (!this.linearCreatedIssueMatchesIntent(created, createFields)) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Linear issue create could not be confirmed with the requested task fields.' + ) + } + return created + }, + (cause) => + this.linearCreateStyleUnconfirmed('create', writeId, null, { + team, + parent, + title: params.title, + bodyRequired: params.body !== undefined, + createFields, + cause + }) + ) + if (parent) { + await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) + } + return this.linearCreateResult(issue, team.workspaceId, writeId, false) + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const issue = await this.refetchLinearIssueAfterDuplicate( + writeId, + team.id, + parentId, + team.workspaceId, + createFields, + () => + this.linearCreateStyleUnconfirmed('create', writeId, null, { + team, + parent, + title: params.title, + bodyRequired: params.body !== undefined, + createFields + }) + ) + if (parent) { + await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) + } + return this.linearCreateResult(issue, team.workspaceId, writeId, true) + } + throw error + } + } + + private async resolveLinearAgentWriteTarget(params: { + input?: string + current?: boolean + workspaceId?: string + context?: LinearCurrentIssueContextHints + }): Promise { + const result = await readLinearIssueContext( + { + input: params.input, + current: params.current, + workspaceId: params.workspaceId, + include: { + comments: false, + children: false, + attachments: false, + relations: false, + activity: false + }, + depth: 0, + context: params.context + }, + (context) => this.linearResolveCurrentIssue(context) + ) + return { issue: result.issue, workspaceId: result.meta.resolved.workspaceId } + } + + private async getLinearTeamStatesForWrite( + teamId: string, + workspaceId: string + ): Promise>> { + try { + return await getLinearTeamStatesOrThrow(teamId, workspaceId) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private resolveLinearAgentState( + input: string, + states: Awaited> + ): Awaited>[number] | null { + const normalized = input.toLocaleLowerCase() + const exact = states.find( + (state) => + state.id.toLocaleLowerCase() === normalized || state.name.toLocaleLowerCase() === normalized + ) + // Why: Linear MCP accepts lifecycle types; keep explicit IDs/names authoritative when they collide. + return exact ?? states.find((state) => state.type.toLocaleLowerCase() === normalized) ?? null + } + + private async getLinearTeamLabelsForWrite( + teamId: string, + workspaceId: string + ): Promise>> { + try { + return await getLinearTeamLabelsOrThrow(teamId, workspaceId) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private async readLinearAgentIssueWriteRecord( + issueId: string, + workspaceId: string + ): Promise>>> { + const issue = await this.readLinearWriteLookup(() => + getLinearIssueByUuidForAgent(issueId, workspaceId) + ) + if (!issue) { + throw linearError('linear_issue_not_found', 'Linear issue was not found.') + } + return issue + } + + private async buildLinearTaskUpdate( + params: LinearIssueTaskUpdateRequest, + current: NonNullable>>, + workspaceId: string + ): Promise<{ + fields: { + assigneeId?: string | null + priority?: number + estimate?: number | null + dueDate?: string | null + labelIds?: string[] + } + labels?: { id: string; name: string }[] + } | null> { + if (params.operation === 'assignee') { + const assigneeId = params.assigneeMe + ? (await this.getLinearViewerForWrite(workspaceId)).id + : params.assigneeId + if (assigneeId === undefined) { + throw linearError('linear_invalid_assignee', 'Pass --me, --to-id, or clear assignee.') + } + return { fields: { assigneeId } } + } + if (params.operation === 'priority') { + if (params.priority === undefined) { + throw linearError('linear_write_failed', 'Missing priority value.') + } + return { fields: { priority: params.priority } } + } + if (params.operation === 'estimate') { + if (params.estimate === undefined) { + throw linearError('linear_write_failed', 'Missing estimate value.') + } + return { fields: { estimate: params.estimate } } + } + if (params.operation === 'dueDate') { + if (params.dueDate === undefined) { + throw linearError('linear_write_failed', 'Missing due date value.') + } + return { fields: { dueDate: params.dueDate } } + } + if (params.operation === 'labels') { + const mode = params.labelMode + const inputs = params.labels ?? [] + if (!mode || inputs.length === 0) { + throw linearError('linear_invalid_label', 'Pass at least one --label.') + } + const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId) + const requestedIds = labels.map((label) => label.id) + const existingIds = current.labelIds ?? current.labels?.map((label) => label.id) ?? [] + const nextIds = + mode === 'set' + ? requestedIds + : mode === 'add' + ? Array.from(new Set([...existingIds, ...requestedIds])) + : existingIds.filter((id) => !requestedIds.includes(id)) + return { + fields: { labelIds: nextIds }, + labels: labelsForIds(nextIds, [...(current.labels ?? []), ...labels]) + } + } + return null + } + + private async buildLinearSaveUpdate( + params: LinearSaveIssueRequest, + current: NonNullable>>, + workspaceId: string + ): Promise { + const fields: LinearIssueUpdate = {} + if (params.title !== undefined) { + fields.title = params.title + } + if (params.description !== undefined) { + fields.description = params.description + } + if (params.priority !== undefined) { + fields.priority = params.priority + } + if (params.estimate !== undefined) { + fields.estimate = params.estimate + } + if (params.dueDate !== undefined) { + fields.dueDate = params.dueDate + } + if (params.state !== undefined) { + const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId) + const state = this.resolveLinearAgentState(params.state, states) + if (!state) { + throw linearError( + 'linear_invalid_state', + `No workflow state exactly matched "${params.state}".` + ) + } + fields.stateId = state.id + } + if (params.assignee !== undefined) { + fields.assigneeId = + params.assignee === null + ? null + : await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId) + } + if (params.labels !== undefined) { + if (params.labels.length === 0) { + fields.labelIds = [] + } else { + const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId) + fields.labelIds = labels.map((label) => label.id) + } + } + if (params.project !== undefined) { + fields.projectId = + params.project === null + ? null + : ( + await this.resolveLinearCreateProject(params.project, { + id: current.team.id, + workspaceId + }) + ).id + } + if (params.parentId !== undefined) { + fields.parentId = + params.parentId === null + ? null + : ( + await this.resolveLinearAgentWriteTarget({ + input: params.parentId, + workspaceId, + context: params.context + }) + ).issue.id + if (fields.parentId === current.id) { + throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.') + } + } + return fields + } + + private async resolveLinearAssignee( + input: string, + teamId: string, + workspaceId: string + ): Promise { + if (input.toLocaleLowerCase() === 'me') { + return (await this.getLinearViewerForWrite(workspaceId)).id + } + // Why: caller-supplied IDs were accepted directly before save-issue; avoid a paginated member scan on that existing fast path. + if (isLinearUuid(input)) { + return input + } + let members: Awaited> + try { + members = await getLinearTeamMembersOrThrow(teamId, workspaceId) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + const normalized = input.toLocaleLowerCase() + const matches = members.filter( + (member) => + member.id.toLocaleLowerCase() === normalized || + member.displayName.toLocaleLowerCase() === normalized || + member.name?.toLocaleLowerCase() === normalized || + member.email?.toLocaleLowerCase() === normalized + ) + if (matches.length === 1) { + return matches[0].id + } + throw linearError( + 'linear_invalid_assignee', + matches.length === 0 + ? `No team member exactly matched "${input}".` + : `Multiple team members exactly matched "${input}".` + ) + } + + private linearSavedIssueMatchesIntent( + issue: NonNullable>>, + fields: LinearIssueUpdate + ): boolean { + if (fields.title !== undefined && issue.title !== fields.title) { + return false + } + if (fields.description !== undefined && (issue.description ?? '') !== fields.description) { + return false + } + if (fields.parentId !== undefined && (issue.parent?.id ?? null) !== fields.parentId) { + return false + } + if (fields.stateId !== undefined && issue.state?.id !== fields.stateId) { + return false + } + if (fields.assigneeId !== undefined && (issue.assignee?.id ?? null) !== fields.assigneeId) { + return false + } + if (fields.priority !== undefined && issue.priority !== fields.priority) { + return false + } + if (fields.estimate !== undefined && (issue.estimate ?? null) !== fields.estimate) { + return false + } + if (fields.dueDate !== undefined && (issue.dueDate ?? null) !== fields.dueDate) { + return false + } + if (fields.projectId !== undefined && (issue.project?.id ?? null) !== fields.projectId) { + return false + } + const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] + return fields.labelIds === undefined || sameStringSet(issueLabelIds, fields.labelIds) + } + + private async resolveLinearCreateFields( + params: { + state?: string + assignee?: string + priority?: number + estimate?: number + dueDate?: string + labels?: string[] + projectInput?: string + }, + team: { id: string; workspaceId: string } + ): Promise { + const fields: LinearCreateFieldIntent = {} + if (params.state) { + const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) + const state = this.resolveLinearAgentState(params.state, states) + if (!state) { + throw linearError( + 'linear_invalid_state', + `No workflow state exactly matched "${params.state}".`, + { states: states.map(({ id, name, type }) => ({ id, name, type })) } + ) + } + fields.stateId = state.id + } + if (params.assignee) { + fields.assigneeId = await this.resolveLinearAssignee( + params.assignee, + team.id, + team.workspaceId + ) + } + if (params.priority !== undefined) { + fields.priority = params.priority + } + if (params.estimate !== undefined) { + fields.estimate = params.estimate + } + if (params.dueDate !== undefined) { + fields.dueDate = params.dueDate + } + if (params.labels && params.labels.length > 0) { + const labels = await this.resolveLinearLabelsForTeam(team.id, params.labels, team.workspaceId) + fields.labelIds = labels.map((label) => label.id) + } + if (params.projectInput) { + const project = await this.resolveLinearCreateProject(params.projectInput, team) + fields.projectId = project.id + } + return fields + } + + private async resolveLinearCreateProject( + input: string, + team: { id: string; workspaceId: string } + ): Promise { + const trimmed = input.trim() + if (!trimmed) { + throw linearError('linear_invalid_project', 'Pass a non-empty Linear project id or name.') + } + const byId = isLinearUuid(trimmed) + ? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId) + : null + if (byId) { + await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed) + return byId + } + const searchCandidates = await this.readLinearProjectsForCreate(trimmed, team.workspaceId) + const normalized = trimmed.toLowerCase() + const idMatch = searchCandidates.find((project) => project.id.toLowerCase() === normalized) + if (idMatch) { + await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed) + return idMatch + } + const slugMatch = searchCandidates.find( + (project) => project.slugId?.toLowerCase() === normalized + ) + if (slugMatch) { + await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed) + return slugMatch + } + const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId) + const compatibleNameMatches = await this.filterLinearProjectsForTeam( + nameMatches, + team.id, + team.workspaceId + ) + if (compatibleNameMatches.length === 1) { + return compatibleNameMatches[0] + } + if (compatibleNameMatches.length > 1) { + throw linearError( + 'linear_invalid_project', + `Multiple Linear projects exactly matched "${trimmed}".`, + { + projects: compatibleNameMatches.map((project) => ({ + id: project.id, + name: project.name, + teams: project.teams + })), + nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] + } + ) + } + if (nameMatches.length > 0) { + await this.assertLinearProjectIncludesTeam(nameMatches[0], team.id, team.workspaceId, trimmed) + } + throw linearError('linear_invalid_project', `No Linear project exactly matched "${trimmed}".`, { + projects: searchCandidates.map((project) => ({ + id: project.id, + name: project.name, + teams: project.teams + })), + nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] + }) + } + + private async readLinearProjectByIdForCreate( + id: string, + workspaceId: string + ): Promise { + try { + return await getLinearProject(id, workspaceId, true) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private async readLinearProjectsForCreate( + query: string, + workspaceId: string + ): Promise { + try { + return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true)).items + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private async readLinearProjectsByExactNameForCreate( + name: string, + workspaceId: string + ): Promise { + try { + return await listLinearProjectsByExactName(name, workspaceId, true) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private async assertLinearProjectIncludesTeam( + project: LinearProjectSummary, + teamId: string, + workspaceId: string, + input: string + ): Promise { + if (this.linearProjectIncludesTeam(project, teamId)) { + return + } + let teams: NonNullable = [] + try { + // Why: summary reads cap project teams, so large cross-team projects need a paged membership check before rejecting a valid create. + teams = await listLinearProjectTeams(project.id, workspaceId, true) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + if (teams.some((team) => team.id === teamId)) { + return + } + throw linearError( + 'linear_invalid_project', + `Linear project "${input}" is not available to the target team.`, + { + project: { id: project.id, name: project.name, teams }, + nextSteps: ['Choose a project that includes the create target team, then retry by id.'] + } + ) + } + + private async filterLinearProjectsForTeam( + projects: LinearProjectSummary[], + teamId: string, + workspaceId: string + ): Promise { + const compatible: LinearProjectSummary[] = [] + for (const project of projects) { + if (this.linearProjectIncludesTeam(project, teamId)) { + compatible.push(project) + continue + } + try { + const teams = await listLinearProjectTeams(project.id, workspaceId, true) + if (teams.some((team) => team.id === teamId)) { + compatible.push({ ...project, teams }) + } + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + return compatible + } + + private linearProjectIncludesTeam(project: LinearProjectSummary, teamId: string): boolean { + return project.teams?.some((team) => team.id === teamId) === true + } + + private async getLinearViewerForWrite( + workspaceId: string + ): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> { + try { + return await getLinearViewerForWorkspaceOrThrow(workspaceId) + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private async resolveLinearLabelsForIssue( + issue: NonNullable>>, + inputs: string[], + workspaceId: string + ): Promise<{ id: string; name: string }[]> { + const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId) + const resolved = inputs.map((input) => { + const normalized = input.toLocaleLowerCase() + const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) + if (idMatch) { + return { id: idMatch.id, name: idMatch.name } + } + const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) + if (nameMatches.length === 1) { + return { id: nameMatches[0].id, name: nameMatches[0].name } + } + throw linearError( + 'linear_invalid_label', + nameMatches.length === 0 + ? `No label exactly matched "${input}".` + : `Multiple labels exactly matched "${input}".`, + { + labels: labels.map((label) => ({ id: label.id, name: label.name })), + nextSteps: ['Run `orca linear team labels --team --json` and retry by id.'] + } + ) + }) + return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) + } + + private async resolveLinearLabelsForTeam( + teamId: string, + inputs: string[], + workspaceId: string + ): Promise<{ id: string; name: string }[]> { + const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId) + const resolved = inputs.map((input) => { + const normalized = input.toLocaleLowerCase() + const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) + if (idMatch) { + return { id: idMatch.id, name: idMatch.name } + } + const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) + if (nameMatches.length === 1) { + return { id: nameMatches[0].id, name: nameMatches[0].name } + } + throw linearError( + 'linear_invalid_label', + nameMatches.length === 0 + ? `No label exactly matched "${input}".` + : `Multiple labels exactly matched "${input}".`, + { labels: labels.map((label) => ({ id: label.id, name: label.name })) } + ) + }) + return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) + } + + private linearCreatedIssueMatchesIntent( + issue: NonNullable>>, + intent: LinearCreateFieldIntent + ): boolean { + if (intent.stateId !== undefined && issue.state?.id !== intent.stateId) { + return false + } + if (intent.assigneeId !== undefined && (issue.assignee?.id ?? null) !== intent.assigneeId) { + return false + } + if (intent.priority !== undefined && issue.priority !== intent.priority) { + return false + } + if (intent.estimate !== undefined && (issue.estimate ?? null) !== intent.estimate) { + return false + } + if (intent.dueDate !== undefined && (issue.dueDate ?? null) !== intent.dueDate) { + return false + } + if (intent.projectId !== undefined && (issue.project?.id ?? null) !== intent.projectId) { + return false + } + const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] + if (intent.labelIds !== undefined && !sameStringSet(issueLabelIds, intent.labelIds)) { + return false + } + return true + } + + private linearTaskFieldAlreadySet( + operation: LinearIssueTaskUpdateRequest['operation'], + record: NonNullable>>, + update: { + fields: { + assigneeId?: string | null + priority?: number + estimate?: number | null + dueDate?: string | null + labelIds?: string[] + } + } + ): boolean { + if (operation === 'assignee') { + return (record.assignee?.id ?? null) === update.fields.assigneeId + } + if (operation === 'priority') { + return record.priority === update.fields.priority + } + if (operation === 'estimate') { + return (record.estimate ?? null) === update.fields.estimate + } + if (operation === 'dueDate') { + return (record.dueDate ?? null) === update.fields.dueDate + } + if (operation === 'labels') { + const recordLabelIds = record.labelIds ?? record.labels?.map((label) => label.id) ?? [] + return sameStringSet(recordLabelIds, update.fields.labelIds ?? []) + } + return false + } + + private linearTaskUpdateResult( + operation: LinearIssueTaskUpdateRequest['operation'], + issue: LinearIssueSummary, + workspaceId: string, + previous: NonNullable>>, + current: NonNullable>>, + alreadySet: boolean + ): LinearIssueTaskUpdateResult { + return { + issue: this.linearWriteIssueRef(issue), + operation, + previous: this.linearTaskResultFields(previous), + current: this.linearTaskResultFields(current), + meta: { workspaceId, alreadySet } + } + } + + private linearTaskResultFields( + record: NonNullable>> + ): LinearIssueTaskUpdateResult['current'] { + return { + assignee: record.assignee ?? null, + priority: record.priority ?? null, + estimate: record.estimate ?? null, + dueDate: record.dueDate ?? null, + labels: record.labels ?? [] + } + } + + private async resolveLinearCommentParentId( + issueId: string, + commentId: string, + workspaceId: string + ): Promise { + try { + const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId) + if (!root) { + throw linearError( + 'linear_invalid_parent', + 'The reply target is not a comment on this issue.', + { + nextSteps: ['Run `orca linear issue --comments --json` to list valid comment ids.'] + } + ) + } + return root.id + } catch (error) { + if (error instanceof LinearAgentAccessError) { + throw error + } + throw this.mapLinearReadFailure(error) + } + } + + private async runLinearAgentWrite( + write: (signal: AbortSignal) => Promise, + unconfirmed: (cause?: string) => LinearAgentAccessError + ): Promise { + const controller = new AbortController() + const writePromise = write(controller.signal) + writePromise.catch(() => undefined) + let timer: ReturnType | null = null + try { + return await Promise.race([ + writePromise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort() + reject( + new LinearWriteFailure( + 'unconfirmed', + 'Linear write deadline elapsed before confirmation.' + ) + ) + }, 25_000) + }) + ]) + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + throw error + } + if (error instanceof LinearWriteFailure && error.kind === 'unconfirmed') { + throw unconfirmed(this.linearWriteFailureCauseMessage(error)) + } + if (error instanceof LinearWriteFailure && error.kind === 'network') { + throw linearError('linear_network_error', sanitizeLinearErrorMessage(error.message)) + } + if (error instanceof LinearWriteFailure) { + throw linearError('linear_write_failed', sanitizeLinearErrorMessage(error.message)) + } + throw this.mapLinearReadFailure(error) + } finally { + if (timer) { + clearTimeout(timer) + } + } + } + + private linearWriteFailureCauseMessage(error: LinearWriteFailure): string { + if (error.cause instanceof Error) { + return sanitizeLinearErrorMessage(error.cause.message) + } + if (error.cause !== undefined) { + return sanitizeLinearErrorMessage(String(error.cause)) + } + return sanitizeLinearErrorMessage(error.message) + } + + private mapLinearReadFailure(error: unknown): LinearAgentAccessError { + if (error instanceof LinearAgentAccessError) { + return error + } + if (isLinearAuthError(error)) { + return linearError('linear_auth_expired', 'Linear authentication expired.', { + nextSteps: ['Reconnect Linear from Orca settings.'] + }) + } + return linearError(classifyLinearError(error), linearMessage(error)) + } + + private async getMatchingLinearCommentWrite( + writeId: string, + issueId: string, + parentId: string | null, + workspaceId: string, + required: boolean + ): Promise> | null> { + const comment = await this.readLinearWriteLookup(() => + getLinearCommentByUuidForAgent(writeId, workspaceId) + ) + if (!comment) { + return null + } + if (comment.issue.id === issueId && comment.parentId === parentId) { + return comment + } + if (required) { + throw linearError( + 'linear_invalid_write_id', + 'The write id belongs to a different comment target.' + ) + } + return null + } + + private async getMatchingLinearAttachmentWrite( + writeId: string, + issueId: string, + workspaceId: string, + required: boolean + ): Promise> | null> { + const attachment = await this.readLinearWriteLookup(() => + getLinearAttachmentByUuidForAgent(writeId, workspaceId) + ) + if (!attachment) { + return null + } + if (attachment.issue.id === issueId) { + return attachment + } + if (required) { + throw linearError( + 'linear_invalid_write_id', + 'The write id belongs to a different attachment target.' + ) + } + return null + } + + private async getMatchingLinearCreatedIssue( + writeId: string, + teamId: string, + parentId: string | null, + workspaceId: string, + required: boolean, + intent: LinearCreateFieldIntent = {} + ): Promise> | null> { + const issue = await this.readLinearWriteLookup(() => + getLinearIssueByUuidForAgent(writeId, workspaceId) + ) + if (!issue) { + return null + } + if ( + issue.team.id === teamId && + (issue.parent?.id ?? null) === parentId && + this.linearCreatedIssueMatchesIntent(issue, intent) + ) { + return issue + } + if (required) { + throw linearError( + 'linear_invalid_write_id', + 'The write id belongs to a different issue target.' + ) + } + return null + } + + private async refetchLinearCommentAfterDuplicate( + writeId: string, + issueId: string, + parentId: string | null, + workspaceId: string, + unconfirmed: (cause?: string) => LinearAgentAccessError + ): Promise>>> { + try { + // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. + const comment = await this.getMatchingLinearCommentWrite( + writeId, + issueId, + parentId, + workspaceId, + true + ) + if (comment) { + return comment + } + } catch (error) { + if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { + throw error + } + throw unconfirmed( + error instanceof Error + ? sanitizeLinearErrorMessage(error.message) + : sanitizeLinearErrorMessage(String(error)) + ) + } + throw unconfirmed() + } + + private async refetchLinearAttachmentAfterDuplicate( + writeId: string, + issueId: string, + workspaceId: string, + unconfirmed: (cause?: string) => LinearAgentAccessError + ): Promise>>> { + try { + // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. + const attachment = await this.getMatchingLinearAttachmentWrite( + writeId, + issueId, + workspaceId, + true + ) + if (attachment) { + return attachment + } + } catch (error) { + if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { + throw error + } + throw unconfirmed( + error instanceof Error + ? sanitizeLinearErrorMessage(error.message) + : sanitizeLinearErrorMessage(String(error)) + ) + } + throw unconfirmed() + } + + private async refetchLinearIssueAfterDuplicate( + writeId: string, + teamId: string, + parentId: string | null, + workspaceId: string, + intent: LinearCreateFieldIntent, + unconfirmed: (cause?: string) => LinearAgentAccessError + ): Promise>>> { + try { + // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. + const issue = await this.getMatchingLinearCreatedIssue( + writeId, + teamId, + parentId, + workspaceId, + true, + intent + ) + if (issue) { + return issue + } + } catch (error) { + if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { + throw error + } + throw unconfirmed( + error instanceof Error + ? sanitizeLinearErrorMessage(error.message) + : sanitizeLinearErrorMessage(String(error)) + ) + } + throw unconfirmed() + } + + private async readLinearWriteLookup(lookup: () => Promise): Promise { + try { + return await lookup() + } catch (error) { + throw this.mapLinearReadFailure(error) + } + } + + private parseLinearAttachmentUrl(value: string): URL { + try { + const url = new URL(value) + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url + } + } catch { + // Fall through to the stable agent-facing error below. + } + throw linearError('linear_invalid_url', 'Attachment URL must be an absolute http(s) URL.') + } + + private defaultLinearAttachmentTitle(url: URL): string { + const tail = url.pathname.split('/').findLast(Boolean) + return tail ? `${url.host}/${tail}` : url.host + } + + private linearWorkspaceErrorCode(type: string): LinearErrorCode { + if (type === 'auth') { + return 'linear_auth_expired' + } + if (type === 'network') { + return 'linear_network_error' + } + if (type === 'rate_limited') { + return 'linear_rate_limited' + } + return 'linear_write_failed' + } + + private linearTeamSummary(team: { + id: string + name: string + key: string + url?: string + workspaceId?: string + workspaceName?: string + }): { + id: string + name: string + key: string + url?: string + workspace?: { id: string; name: string } + } { + return { + id: team.id, + name: team.name, + key: team.key, + ...(team.url ? { url: team.url } : {}), + ...(team.workspaceId + ? { workspace: { id: team.workspaceId, name: team.workspaceName ?? team.workspaceId } } + : {}) + } + } + + private async resolveLinearTeamInput( + teamInput: string, + workspaceId?: (string & {}) | 'all' + ): Promise<{ + id: string + key: string + name: string + workspaceId: string + workspaceName?: string + }> { + this.validateLinearCreateWorkspaceScope(workspaceId === 'all' ? undefined : workspaceId) + let teams: Awaited> + try { + teams = await listLinearTeamsOrThrow(workspaceId ?? 'all') + } catch (error) { + throw this.mapLinearReadFailure(error) + } + const normalized = teamInput.toLocaleLowerCase() + const idMatches = teams.filter((team) => team.id.toLocaleLowerCase() === normalized) + const matches = + idMatches.length > 0 + ? idMatches + : teams.filter((team) => team.key.toLocaleLowerCase() === normalized) + if (matches.length === 1 && matches[0].workspaceId) { + return { + id: matches[0].id, + key: matches[0].key, + name: matches[0].name, + workspaceId: matches[0].workspaceId, + workspaceName: matches[0].workspaceName + } + } + if (matches.length > 1) { + throw linearError( + 'linear_workspace_ambiguous', + `Team ${teamInput} exists in multiple workspaces.`, + { + candidates: matches.map((team) => ({ + workspaceId: team.workspaceId, + workspaceName: team.workspaceName, + teamId: team.id, + teamKey: team.key + })) + } + ) + } + throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) + } + + private async resolveLinearCreateTeam( + teamInput: string | undefined, + workspaceId: string | undefined, + parent: LinearAgentWriteTarget | null + ): Promise<{ id: string; key: string; name: string; workspaceId: string }> { + if (!teamInput && parent?.issue.team?.id && parent.issue.team.key && parent.issue.team.name) { + return { + id: parent.issue.team.id, + key: parent.issue.team.key, + name: parent.issue.team.name, + workspaceId: parent.workspaceId + } + } + if (!teamInput) { + throw linearError('linear_team_required', 'Pass --team or create under a parent issue.', { + nextSteps: ['Run `orca linear create --team ...` or use --parent-current.'] + }) + } + + const scope = parent?.workspaceId ?? workspaceId + this.validateLinearCreateWorkspaceScope(scope) + let teams: Awaited> + try { + teams = await listLinearTeamsOrThrow(scope ?? 'all') + } catch (error) { + throw this.mapLinearReadFailure(error) + } + if (teams.length === 0 && (getLinearStatus().workspaces?.length ?? 0) === 0) { + throw linearError('linear_not_connected', 'Linear is not connected.', { + nextSteps: ['Connect Linear from Orca settings, then retry the issue create.'] + }) + } + const matches = teams.filter( + (team) => + team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || + team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() + ) + if (matches.length === 1 && matches[0].workspaceId) { + return { + id: matches[0].id, + key: matches[0].key, + name: matches[0].name, + workspaceId: matches[0].workspaceId + } + } + if (matches.length > 1) { + throw linearError( + 'linear_workspace_ambiguous', + `Team ${teamInput} exists in multiple workspaces.`, + { + candidates: matches.map((team) => ({ + workspaceId: team.workspaceId, + workspaceName: team.workspaceName, + teamKey: team.key + })) + } + ) + } + if (parent) { + let globalTeams: Awaited> + try { + globalTeams = await listLinearTeamsOrThrow('all') + } catch (error) { + throw this.mapLinearReadFailure(error) + } + const globalMatch = globalTeams.find( + (team) => + team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || + team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() + ) + if (globalMatch) { + throw linearError( + 'linear_invalid_workspace', + `Team ${teamInput} is not in the parent issue workspace.` + ) + } + } + throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) + } + + private validateLinearCreateWorkspaceScope(workspaceId: string | undefined): void { + if (!workspaceId) { + return + } + const workspaces = getLinearStatus().workspaces ?? [] + if (workspaces.length > 0 && !workspaces.some((workspace) => workspace.id === workspaceId)) { + throw linearError( + 'linear_invalid_workspace', + `No connected Linear workspace matched ${workspaceId}.` + ) + } + } + + private linearWriteIssueRef(issue: { id: string; identifier: string; url: string }): { + id: string + identifier: string + url: string + } { + return { id: issue.id, identifier: issue.identifier, url: issue.url } + } + + private linearCommentResult( + comment: NonNullable>>, + target: LinearAgentWriteTarget, + bodyChars: number, + writeId: string, + deduplicated: boolean + ): LinearCommentAddResult { + return { + comment: { id: comment.id, url: comment.url, parentId: comment.parentId }, + issue: this.linearWriteIssueRef(target.issue), + meta: { workspaceId: target.workspaceId, bodyChars, writeId, deduplicated } + } + } + + private linearAttachResult( + attachment: NonNullable>>, + target: LinearAgentWriteTarget, + writeId: string, + deduplicated: boolean + ): LinearAttachResult { + return { + attachment: { id: attachment.id, title: attachment.title, url: attachment.url }, + issue: this.linearWriteIssueRef(target.issue), + meta: { workspaceId: target.workspaceId, writeId, deduplicated } + } + } + + private linearCreateResult( + issue: NonNullable>>, + workspaceId: string, + writeId: string, + deduplicated: boolean + ): LinearCreateResult { + return { + issue, + meta: { workspaceId, writeId, deduplicated } + } + } + + private linearCreateFieldRetryTokens(fields: LinearCreateFieldIntent | undefined): string[] { + if (!fields) { + return [] + } + return [ + ...(fields.stateId ? [`--state=${this.commandToken(fields.stateId, 'STATE_ID')}`] : []), + ...(fields.assigneeId + ? [`--assignee=${this.commandToken(fields.assigneeId, 'ASSIGNEE_ID')}`] + : []), + ...(fields.priority !== undefined + ? [`--priority=${this.linearPriorityRetryToken(fields.priority)}`] + : []), + ...(fields.estimate !== undefined && fields.estimate !== null + ? [`--estimate=${fields.estimate}`] + : []), + ...(fields.dueDate ? [`--due-date=${fields.dueDate}`] : []), + ...(fields.projectId + ? [`--project=${this.commandToken(fields.projectId, 'PROJECT_ID')}`] + : []), + ...(fields.labelIds ?? []).map( + (labelId) => `--label=${this.commandToken(labelId, 'LABEL_ID')}` + ) + ] + } + + private linearPriorityRetryToken(priority: number): string { + if (priority === 1) { + return 'urgent' + } + if (priority === 2) { + return 'high' + } + if (priority === 3) { + return 'medium' + } + if (priority === 4) { + return 'low' + } + return 'none' + } + + private linearCreateStyleUnconfirmed( + verb: 'comment' | 'attach' | 'create', + writeId: string, + target: LinearAgentWriteTarget | null, + extra: { + parentId?: string | null + team?: { id: string; key: string; name: string; workspaceId: string } + parent?: LinearAgentWriteTarget | null + title?: string + url?: string + bodyRequired?: boolean + createFields?: LinearCreateFieldIntent + cause?: string + } = {} + ): LinearAgentAccessError { + const workspaceId = target?.workspaceId ?? extra.team?.workspaceId ?? '' + // Why: the retry preserves id and target so duplicate recovery can prove intent without matching mutable content. + const pinned = + verb === 'create' + ? [ + 'orca linear create', + `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, + `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, + '--title TITLE_HERE', + ...(extra.bodyRequired ? ['--body-file -'] : []), + ...(extra.parent + ? [`--parent=${this.commandToken(extra.parent.issue.identifier, 'PARENT_ISSUE')}`] + : []), + ...(extra.team + ? [`--team=${this.commandToken(extra.team.key, 'TEAM_KEY')}`] + : [] + ).concat(this.linearCreateFieldRetryTokens(extra.createFields)) + ].join(' ') + : [ + `orca linear ${verb === 'attach' ? 'attach' : 'comment add'}`, + this.commandToken(target?.issue.identifier ?? '', 'ISSUE_ID'), + `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, + `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, + ...(verb === 'comment' ? ['--body-file -'] : []), + ...(verb === 'comment' && extra.parentId + ? [`--reply-to=${this.commandToken(extra.parentId, 'COMMENT_ID')}`] + : []), + ...(verb === 'attach' ? ['--url URL_HERE', '--title TITLE_HERE'] : []) + ].join(' ') + const retryPrefix = extra.bodyRequired || verb === 'comment' ? 'Pipe the same body and r' : 'R' + const payloadNote = + verb === 'attach' + ? ' Replace TITLE_HERE/URL_HERE with the exact original payload values before running.' + : verb === 'create' + ? ' Replace TITLE_HERE with the exact original title before running.' + : '' + return linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the write, but Orca could not confirm it.', + { + writeId, + workspaceId, + issueIdentifier: target?.issue.identifier, + parentId: extra.parentId, + team: extra.team ? { id: extra.team.id, key: extra.team.key } : undefined, + parentIdentifier: extra.parent?.issue.identifier, + createFields: extra.createFields, + nextSteps: [ + `${retryPrefix}etry once with the pinned command: \`${pinned}\`.${payloadNote}` + ], + ...(extra.cause ? { cause: sanitizeLinearErrorMessage(extra.cause) } : {}) + } + ) + } + + private commandToken(value: string, placeholder: string): string { + return /^[A-Za-z0-9._:@%+=,/-]+$/.test(value) ? value : placeholder + } + + private async notifyLinearLinkedIssueUpdated( + workspaceId: string, + identifier: string | readonly string[] + ): Promise { + const identifiers = typeof identifier === 'string' ? [identifier] : identifier + const normalized = new Map( + identifiers.map((value) => [value.toLocaleUpperCase(), value] as const) + ) + for (const worktree of await this.listResolvedWorktrees()) { + const linkedIdentifier = normalized.get( + (worktree.linkedLinearIssue ?? '').toLocaleUpperCase() + ) + if (!linkedIdentifier) { + continue + } + const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? workspaceId + if (linkedWorkspaceId !== workspaceId) { + continue + } + this.emitClientEvent({ + type: 'linearLinkedIssueUpdated', + worktreeId: worktree.id, + identifier: linkedIdentifier, + workspaceId + }) + } + } + + linearIssueComments( + issueId: string, + workspaceId?: string + ): ReturnType { + return getLinearIssueComments(issueId, workspaceId) + } + + linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType { + return listLinearTeams(workspaceId) + } + + linearListProjects( + query?: string, + limit = 20, + workspaceId?: LinearWorkspaceSelection, + force?: boolean + ): ReturnType { + return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) + } + + linearCreateProject( + input: LinearProjectCreateInput, + workspaceId?: string + ): ReturnType { + return createLinearProject(input, workspaceId) + } + + linearGetProject( + id: string, + workspaceId: string, + force?: boolean + ): ReturnType { + return getLinearProject(id, workspaceId, force) + } + + linearListProjectIssues( + projectId: string, + limit = 20, + workspaceId: string, + force?: boolean + ): ReturnType { + return listLinearProjectIssues(projectId, clampLinearIssueListLimit(limit), workspaceId, force) + } + + linearListCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection, + force?: boolean + ): ReturnType { + return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId, force) + } + + linearGetCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: string, + force?: boolean + ): ReturnType { + return getLinearCustomView(viewId, model, workspaceId, force) + } + + linearListCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: string, + force?: boolean + ): ReturnType { + return listLinearCustomViewIssues(viewId, clampLinearIssueListLimit(limit), workspaceId, force) + } + + linearListCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: string, + force?: boolean + ): ReturnType { + return listLinearCustomViewProjects( + viewId, + Math.min(Math.max(1, limit), 50), + workspaceId, + force + ) + } + + linearTeamStates(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamStates(teamId, workspaceId) + } + + linearTeamLabels(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamLabels(teamId, workspaceId) + } + + linearTeamMembers(teamId: string, workspaceId?: string): ReturnType { + return getLinearTeamMembers(teamId, workspaceId) + } + + // ── Jira integration ── + + jiraConnect(args: JiraConnectArgs): ReturnType { + return connectJira(args) + } + + jiraDisconnect(siteId?: string): { ok: true } { + disconnectJira(siteId) + return { ok: true } + } + + jiraSelectSite(siteId: JiraSiteSelection): ReturnType { + return selectJiraSite(siteId) + } + + jiraStatus(): ReturnType { + return getJiraStatus() + } + + jiraReadStatus(): ReturnType { + return getJiraStatus() + } + + jiraTestConnection(siteId?: string): ReturnType { + return testJiraConnection(siteId) + } + + jiraSearchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection, + signal?: AbortSignal + ): ReturnType { + return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId, signal) + } + + jiraListIssues( + filter?: JiraIssueFilter, + limit = 30, + siteId?: JiraSiteSelection + ): ReturnType { + return listJiraIssues(filter, Math.min(Math.max(1, limit), 100), siteId) + } + + jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType { + return createJiraIssue(args) + } + + jiraGetIssue(key: string, siteId?: string): ReturnType { + return getJiraIssue(key, siteId) + } + + jiraLookupIssueSummary( + key: string, + siteId: string, + signal?: AbortSignal + ): ReturnType { + return getJiraIssueSummary(key, siteId, signal) + } + + jiraUpdateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string + ): ReturnType { + return updateJiraIssue(key, updates, siteId) + } + + jiraAddIssueComment( + key: string, + body: string, + siteId?: string + ): ReturnType { + return addJiraIssueComment(key, body, siteId) + } + + jiraIssueComments(key: string, siteId?: string): ReturnType { + return getJiraIssueComments(key, siteId) + } + + jiraListProjects(siteId?: JiraSiteSelection): ReturnType { + return listJiraProjects(siteId) + } + + jiraListIssueTypes( + projectIdOrKey: string, + siteId?: string + ): ReturnType { + return listJiraIssueTypes(projectIdOrKey, siteId) + } + + jiraListCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string + ): ReturnType { + return listJiraCreateFields(projectIdOrKey, issueTypeId, siteId) + } + + jiraListPriorities(siteId?: string): ReturnType { + return listJiraPriorities(siteId) + } + + jiraListAssignableUsers( + key: string, + query?: string, + siteId?: string + ): ReturnType { + return listJiraAssignableUsers(key, query, siteId) + } + + /** Searches all users on the site, for reporter and user-picker create fields. */ + jiraSearchUsers(query?: string, siteId?: string): ReturnType { + return searchJiraUsers(query, siteId) + } + + jiraListTransitions(key: string, siteId?: string): ReturnType { + return listJiraTransitions(key, siteId) + } + + jiraGetProjectStatusOrder( + projectKey: string, + siteId?: string + ): ReturnType { + return getJiraProjectStatusOrder(projectKey, siteId) + } + + // ── Browser automation ── + + routeClientHostedBrowserRpc( + method: string, + params: unknown + ): Promise { + return routeRuntimeBrowserClientAutomation({ + method, + params, + pages: getRuntimeBrowserPageRegistry(this), + leases: getBrowserHostLeaseRegistry(this), + resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) + }) + } + + private readonly browserCommands = createRuntimeBrowserCommands({ + getAgentBrowserBridge: () => this.agentBrowserBridge, + resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveBrowserWorkspace: (selector) => this.resolveBrowserWorkspace(selector), + getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this), + getRuntimeBrowserPageRegistry: () => getRuntimeBrowserPageRegistry(this), + resolveBrowserNetworkExecutionHost: (worktree) => + this.resolveBrowserNetworkExecutionHostForWorktree(worktree), + getAuthoritativeWindow: () => this.getAuthoritativeWindow(), + getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow(), + getOffscreenBrowserBackend: () => this.offscreenBrowserBackend, + // Why: bind directly, not a wrapper arrow — a hand-listed wrapper dropped targetGroupId, so a right-split browser landed in the left. + markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this), + notifyHeadlessBrowserSessionTabsChanged: (worktreeId) => + this.notifyMobileSessionTabsChanged(worktreeId), + retireRuntimeOwnedBrowserSessionTab: (worktreeId, browserPageId) => + this.retireRuntimeOwnedBrowserSessionTab(worktreeId, browserPageId) + }) + + private readonly emulatorCommands = new RuntimeEmulatorCommands({ + getEmulatorBridge: () => this.emulatorBridge, + resolveEmulatorWorkspaceId: (selector) => this.resolveEmulatorWorkspaceId(selector), + resolveEmulatorCleanupWorkspaceId: (selector) => + this.resolveEmulatorCleanupWorkspaceId(selector), + getAuthoritativeWindow: () => this.getAuthoritativeWindow(), + getSettings: () => this.requireStore().getSettings() + }) + + browserSnapshot: RuntimeBrowserCommands['browserSnapshot'] = + this.browserCommands.browserSnapshot.bind(this.browserCommands) + + browserClick: RuntimeBrowserCommands['browserClick'] = this.browserCommands.browserClick.bind( + this.browserCommands + ) + + browserGoto: RuntimeBrowserCommands['browserGoto'] = this.browserCommands.browserGoto.bind( + this.browserCommands + ) + + browserFill: RuntimeBrowserCommands['browserFill'] = this.browserCommands.browserFill.bind( + this.browserCommands + ) + + browserType: RuntimeBrowserCommands['browserType'] = this.browserCommands.browserType.bind( + this.browserCommands + ) + + browserSelect: RuntimeBrowserCommands['browserSelect'] = this.browserCommands.browserSelect.bind( + this.browserCommands + ) + + browserScroll: RuntimeBrowserCommands['browserScroll'] = this.browserCommands.browserScroll.bind( + this.browserCommands + ) + + browserBack: RuntimeBrowserCommands['browserBack'] = this.browserCommands.browserBack.bind( + this.browserCommands + ) + + browserReload: RuntimeBrowserCommands['browserReload'] = this.browserCommands.browserReload.bind( + this.browserCommands + ) + + browserScreenshot: RuntimeBrowserCommands['browserScreenshot'] = + this.browserCommands.browserScreenshot.bind(this.browserCommands) + + async browserScreencast( + params: Parameters[0], + options: { + connectionId?: string + pairedDeviceId?: string + clientKind?: 'mobile' | 'runtime' + sendBinary?: (bytes: Uint8Array) => boolean | void + signal?: AbortSignal + emit: (result: BrowserScreencastResult) => void + } + ): Promise { + if (!options.sendBinary) { + throw new BrowserError( + 'browser_error', + 'Browser screencast requires a binary streaming transport.' + ) + } + + const connectionKey = options.connectionId ?? 'local' + const drivesAsMobile = screencastSubscriberDrivesAsMobile(options.clientKind) + let existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) + while (existingStream) { + existingStream.cancel() + await existingStream.done + existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) + } + if (options.signal?.aborted) { + throw new BrowserError('browser_error', 'Browser screencast was cancelled.') + } + + let screencast: Awaited> | null = null + let registeredSubscriptionId: string | null = null + let activeBrowserPageId: string | null = null + let activePageStream: BrowserScreencastSubscriber | null = null + let ended = false + let cancelledBeforeStart = false + let readyEmitted = false + let resolveActiveDone!: () => void + const activeDone = new Promise((resolve) => { + resolveActiveDone = resolve + }) + const end = (emitEnd: boolean): void => { + if (ended) { + return + } + ended = true + screencast?.session.stop() + if (emitEnd && screencast) { + options.emit({ type: 'end', subscriptionId: screencast.subscriptionId }) + } + } + const cancel = (emitEnd = false): void => { + if (!screencast) { + cancelledBeforeStart = true + return + } + end(emitEnd) + } + const abortScreencast = (): void => cancel() + const sendBinaryAfterReady = (bytes: Uint8Array): boolean | void => { + if (!readyEmitted) { + // Why: clients learn the owning subscription from ready, so CDP frames must stay unacked until the JSON ready event is delivered. + return false + } + return options.sendBinary?.(bytes) + } + + // Why: a phone can rotate before the first stream reaches ready (no subscriptionId yet), so a same-socket replacement cancels and waits here instead of racing. + this.activeBrowserScreencastsByConnection.set(connectionKey, { + cancel, + done: activeDone, + connectionKey + }) + options.signal?.addEventListener('abort', abortScreencast, { once: true }) + try { + screencast = await this.browserCommands.browserScreencast(params, { + sendBinary: sendBinaryAfterReady, + emit: options.emit, + pairedDeviceId: options.pairedDeviceId + }) + if (cancelledBeforeStart || options.signal?.aborted) { + end(false) + await screencast.session.done + return + } + activeBrowserPageId = screencast.ready.browserPageId + activePageStream = { + cancel, + done: activeDone, + connectionKey, + drivesAsMobile + } + const pageStreams = + this.activeBrowserScreencastsByPage.get(activeBrowserPageId) ?? + new Set() + pageStreams.add(activePageStream) + this.activeBrowserScreencastsByPage.set(activeBrowserPageId, pageStreams) + this.publishBrowserRemoteViewers(activeBrowserPageId) + if (drivesAsMobile) { + this.setBrowserDriver(activeBrowserPageId, { kind: 'mobile', clientId: connectionKey }) + } + + // Why: screencast frames are connection-scoped; tie Page.stopScreencast to the exact socket so dropped connections don't leave Chromium streaming. + this.registerSubscriptionCleanup( + screencast.subscriptionId, + () => end(true), + options.connectionId + ) + registeredSubscriptionId = screencast.subscriptionId + options.emit(screencast.ready) + readyEmitted = true + // Why: a joining subscriber's viewport snapshot is captured before this gate opens, and + // on a static page Chromium emits nothing after it — without the replay the pane stays blank. + screencast.flushPendingFrame() + await screencast.session.done + end(true) + this.cleanupSubscription(screencast.subscriptionId) + } finally { + options.signal?.removeEventListener('abort', abortScreencast) + if (!ended) { + end(false) + } + if (registeredSubscriptionId) { + this.cleanupSubscription(registeredSubscriptionId) + } + const active = this.activeBrowserScreencastsByConnection.get(connectionKey) + if (active?.done === activeDone) { + this.activeBrowserScreencastsByConnection.delete(connectionKey) + } + if (activeBrowserPageId) { + const pageStreams = this.activeBrowserScreencastsByPage.get(activeBrowserPageId) + if (activePageStream && pageStreams) { + pageStreams.delete(activePageStream) + if (pageStreams.size === 0) { + this.activeBrowserScreencastsByPage.delete(activeBrowserPageId) + } + } + this.publishBrowserRemoteViewers(activeBrowserPageId) + const driver = this.getBrowserDriver(activeBrowserPageId) + if (driver.kind === 'mobile' && driver.clientId === connectionKey) { + this.setBrowserDriver( + activeBrowserPageId, + resolveBrowserDriverAfterMobileRelease(pageStreams ?? []) + ) + } + } + resolveActiveDone() + } + } + + browserEval: RuntimeBrowserCommands['browserEval'] = this.browserCommands.browserEval.bind( + this.browserCommands + ) + + browserTabList: RuntimeBrowserCommands['browserTabList'] = + this.browserCommands.browserTabList.bind(this.browserCommands) + browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = + this.browserCommands.browserProceedCertificate.bind(this.browserCommands) + + browserTabShow: RuntimeBrowserCommands['browserTabShow'] = + this.browserCommands.browserTabShow.bind(this.browserCommands) + + browserTabCurrent: RuntimeBrowserCommands['browserTabCurrent'] = + this.browserCommands.browserTabCurrent.bind(this.browserCommands) + + browserTabSwitch: RuntimeBrowserCommands['browserTabSwitch'] = + this.browserCommands.browserTabSwitch.bind(this.browserCommands) + + browserHover: RuntimeBrowserCommands['browserHover'] = this.browserCommands.browserHover.bind( + this.browserCommands + ) + + browserDrag: RuntimeBrowserCommands['browserDrag'] = this.browserCommands.browserDrag.bind( + this.browserCommands + ) + + browserUpload: RuntimeBrowserCommands['browserUpload'] = this.browserCommands.browserUpload.bind( + this.browserCommands + ) + + browserWait: RuntimeBrowserCommands['browserWait'] = this.browserCommands.browserWait.bind( + this.browserCommands + ) + + browserCheck: RuntimeBrowserCommands['browserCheck'] = this.browserCommands.browserCheck.bind( + this.browserCommands + ) + + browserFocus: RuntimeBrowserCommands['browserFocus'] = this.browserCommands.browserFocus.bind( + this.browserCommands + ) + + browserClear: RuntimeBrowserCommands['browserClear'] = this.browserCommands.browserClear.bind( + this.browserCommands + ) + + browserSelectAll: RuntimeBrowserCommands['browserSelectAll'] = + this.browserCommands.browserSelectAll.bind(this.browserCommands) + + browserKeypress: RuntimeBrowserCommands['browserKeypress'] = + this.browserCommands.browserKeypress.bind(this.browserCommands) + + browserPdf: RuntimeBrowserCommands['browserPdf'] = this.browserCommands.browserPdf.bind( + this.browserCommands + ) + + browserFullScreenshot: RuntimeBrowserCommands['browserFullScreenshot'] = + this.browserCommands.browserFullScreenshot.bind(this.browserCommands) + + browserCookieGet: RuntimeBrowserCommands['browserCookieGet'] = + this.browserCommands.browserCookieGet.bind(this.browserCommands) + + browserCookieSet: RuntimeBrowserCommands['browserCookieSet'] = + this.browserCommands.browserCookieSet.bind(this.browserCommands) + + browserCookieDelete: RuntimeBrowserCommands['browserCookieDelete'] = + this.browserCommands.browserCookieDelete.bind(this.browserCommands) + + browserSetViewport: RuntimeBrowserCommands['browserSetViewport'] = + this.browserCommands.browserSetViewport.bind(this.browserCommands) + + browserSetGeolocation: RuntimeBrowserCommands['browserSetGeolocation'] = + this.browserCommands.browserSetGeolocation.bind(this.browserCommands) + + browserInterceptEnable: RuntimeBrowserCommands['browserInterceptEnable'] = + this.browserCommands.browserInterceptEnable.bind(this.browserCommands) + + browserInterceptDisable: RuntimeBrowserCommands['browserInterceptDisable'] = + this.browserCommands.browserInterceptDisable.bind(this.browserCommands) + + browserInterceptList: RuntimeBrowserCommands['browserInterceptList'] = + this.browserCommands.browserInterceptList.bind(this.browserCommands) + + browserCaptureStart: RuntimeBrowserCommands['browserCaptureStart'] = + this.browserCommands.browserCaptureStart.bind(this.browserCommands) + + browserCaptureStop: RuntimeBrowserCommands['browserCaptureStop'] = + this.browserCommands.browserCaptureStop.bind(this.browserCommands) + + browserConsoleLog: RuntimeBrowserCommands['browserConsoleLog'] = + this.browserCommands.browserConsoleLog.bind(this.browserCommands) + + browserNetworkLog: RuntimeBrowserCommands['browserNetworkLog'] = + this.browserCommands.browserNetworkLog.bind(this.browserCommands) + + browserDblclick: RuntimeBrowserCommands['browserDblclick'] = + this.browserCommands.browserDblclick.bind(this.browserCommands) + + browserForward: RuntimeBrowserCommands['browserForward'] = + this.browserCommands.browserForward.bind(this.browserCommands) + + browserScrollIntoView: RuntimeBrowserCommands['browserScrollIntoView'] = + this.browserCommands.browserScrollIntoView.bind(this.browserCommands) + + browserGet: RuntimeBrowserCommands['browserGet'] = this.browserCommands.browserGet.bind( + this.browserCommands + ) + + browserIs: RuntimeBrowserCommands['browserIs'] = this.browserCommands.browserIs.bind( + this.browserCommands + ) + + browserKeyboardInsertText: RuntimeBrowserCommands['browserKeyboardInsertText'] = + this.browserCommands.browserKeyboardInsertText.bind(this.browserCommands) + + browserMouseMove: RuntimeBrowserCommands['browserMouseMove'] = + this.browserCommands.browserMouseMove.bind(this.browserCommands) + + browserMouseDown: RuntimeBrowserCommands['browserMouseDown'] = + this.browserCommands.browserMouseDown.bind(this.browserCommands) + + browserMouseClick: RuntimeBrowserCommands['browserMouseClick'] = + this.browserCommands.browserMouseClick.bind(this.browserCommands) + + browserMouseUp: RuntimeBrowserCommands['browserMouseUp'] = + this.browserCommands.browserMouseUp.bind(this.browserCommands) + + browserMouseWheel: RuntimeBrowserCommands['browserMouseWheel'] = + this.browserCommands.browserMouseWheel.bind(this.browserCommands) + + browserFind: RuntimeBrowserCommands['browserFind'] = this.browserCommands.browserFind.bind( + this.browserCommands + ) + + browserSetDevice: RuntimeBrowserCommands['browserSetDevice'] = + this.browserCommands.browserSetDevice.bind(this.browserCommands) + + browserSetOffline: RuntimeBrowserCommands['browserSetOffline'] = + this.browserCommands.browserSetOffline.bind(this.browserCommands) + + browserSetHeaders: RuntimeBrowserCommands['browserSetHeaders'] = + this.browserCommands.browserSetHeaders.bind(this.browserCommands) + + browserSetCredentials: RuntimeBrowserCommands['browserSetCredentials'] = + this.browserCommands.browserSetCredentials.bind(this.browserCommands) + + browserSetMedia: RuntimeBrowserCommands['browserSetMedia'] = + this.browserCommands.browserSetMedia.bind(this.browserCommands) + + browserClipboardRead: RuntimeBrowserCommands['browserClipboardRead'] = + this.browserCommands.browserClipboardRead.bind(this.browserCommands) + + browserClipboardWrite: RuntimeBrowserCommands['browserClipboardWrite'] = + this.browserCommands.browserClipboardWrite.bind(this.browserCommands) + + browserDialogAccept: RuntimeBrowserCommands['browserDialogAccept'] = + this.browserCommands.browserDialogAccept.bind(this.browserCommands) + + browserDialogDismiss: RuntimeBrowserCommands['browserDialogDismiss'] = + this.browserCommands.browserDialogDismiss.bind(this.browserCommands) + + browserStorageLocalGet: RuntimeBrowserCommands['browserStorageLocalGet'] = + this.browserCommands.browserStorageLocalGet.bind(this.browserCommands) + + browserStorageLocalSet: RuntimeBrowserCommands['browserStorageLocalSet'] = + this.browserCommands.browserStorageLocalSet.bind(this.browserCommands) + + browserStorageLocalClear: RuntimeBrowserCommands['browserStorageLocalClear'] = + this.browserCommands.browserStorageLocalClear.bind(this.browserCommands) + + browserStorageSessionGet: RuntimeBrowserCommands['browserStorageSessionGet'] = + this.browserCommands.browserStorageSessionGet.bind(this.browserCommands) + + browserStorageSessionSet: RuntimeBrowserCommands['browserStorageSessionSet'] = + this.browserCommands.browserStorageSessionSet.bind(this.browserCommands) + + browserStorageSessionClear: RuntimeBrowserCommands['browserStorageSessionClear'] = + this.browserCommands.browserStorageSessionClear.bind(this.browserCommands) + + browserDownload: RuntimeBrowserCommands['browserDownload'] = + this.browserCommands.browserDownload.bind(this.browserCommands) + + browserHighlight: RuntimeBrowserCommands['browserHighlight'] = + this.browserCommands.browserHighlight.bind(this.browserCommands) + + browserExec: RuntimeBrowserCommands['browserExec'] = this.browserCommands.browserExec.bind( + this.browserCommands + ) + + browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = + this.browserCommands.browserTabCreate.bind(this.browserCommands) + + browserOpenUrlOnClient: RuntimeBrowserCommands['browserOpenUrlOnClient'] = + this.browserCommands.browserOpenUrlOnClient.bind(this.browserCommands) + + browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = + this.browserCommands.browserTabSetProfile.bind(this.browserCommands) + + browserTabProfileShow: RuntimeBrowserCommands['browserTabProfileShow'] = + this.browserCommands.browserTabProfileShow.bind(this.browserCommands) + + browserTabProfileClone: RuntimeBrowserCommands['browserTabProfileClone'] = + this.browserCommands.browserTabProfileClone.bind(this.browserCommands) + + browserProfileList: RuntimeBrowserCommands['browserProfileList'] = + this.browserCommands.browserProfileList.bind(this.browserCommands) + + browserProfileCreate: RuntimeBrowserCommands['browserProfileCreate'] = + this.browserCommands.browserProfileCreate.bind(this.browserCommands) + + browserProfileDelete: RuntimeBrowserCommands['browserProfileDelete'] = + this.browserCommands.browserProfileDelete.bind(this.browserCommands) + + browserProfileDetectBrowsers: RuntimeBrowserCommands['browserProfileDetectBrowsers'] = + this.browserCommands.browserProfileDetectBrowsers.bind(this.browserCommands) + + browserProfileImportFromBrowser: RuntimeBrowserCommands['browserProfileImportFromBrowser'] = + this.browserCommands.browserProfileImportFromBrowser.bind(this.browserCommands) + + browserProfileClearDefaultCookies: RuntimeBrowserCommands['browserProfileClearDefaultCookies'] = + this.browserCommands.browserProfileClearDefaultCookies.bind(this.browserCommands) + + browserTabClose: RuntimeBrowserCommands['browserTabClose'] = + this.browserCommands.browserTabClose.bind(this.browserCommands) + + // Emulator bindings (delegated to dedicated commands for surface separation). + emulatorTap: RuntimeEmulatorCommands['emulatorTap'] = this.emulatorCommands.emulatorTap.bind( + this.emulatorCommands + ) + emulatorGesture: RuntimeEmulatorCommands['emulatorGesture'] = + this.emulatorCommands.emulatorGesture.bind(this.emulatorCommands) + emulatorType: RuntimeEmulatorCommands['emulatorType'] = this.emulatorCommands.emulatorType.bind( + this.emulatorCommands + ) + emulatorButton: RuntimeEmulatorCommands['emulatorButton'] = + this.emulatorCommands.emulatorButton.bind(this.emulatorCommands) + emulatorRotate: RuntimeEmulatorCommands['emulatorRotate'] = + this.emulatorCommands.emulatorRotate.bind(this.emulatorCommands) + emulatorExec: RuntimeEmulatorCommands['emulatorExec'] = this.emulatorCommands.emulatorExec.bind( + this.emulatorCommands + ) + emulatorAttach: RuntimeEmulatorCommands['emulatorAttach'] = + this.emulatorCommands.emulatorAttach.bind(this.emulatorCommands) + emulatorList: RuntimeEmulatorCommands['emulatorList'] = this.emulatorCommands.emulatorList.bind( + this.emulatorCommands + ) + emulatorKill: RuntimeEmulatorCommands['emulatorKill'] = this.emulatorCommands.emulatorKill.bind( + this.emulatorCommands + ) + emulatorShutdown: RuntimeEmulatorCommands['emulatorShutdown'] = + this.emulatorCommands.emulatorShutdown.bind(this.emulatorCommands) + emulatorListSimulators: RuntimeEmulatorCommands['emulatorListSimulators'] = + this.emulatorCommands.emulatorListSimulators.bind(this.emulatorCommands) + emulatorAvailability: RuntimeEmulatorCommands['emulatorAvailability'] = + this.emulatorCommands.emulatorAvailability.bind(this.emulatorCommands) + emulatorListDevices: RuntimeEmulatorCommands['emulatorListDevices'] = + this.emulatorCommands.emulatorListDevices.bind(this.emulatorCommands) + emulatorInstall: RuntimeEmulatorCommands['emulatorInstall'] = + this.emulatorCommands.emulatorInstall.bind(this.emulatorCommands) + emulatorLaunch: RuntimeEmulatorCommands['emulatorLaunch'] = + this.emulatorCommands.emulatorLaunch.bind(this.emulatorCommands) + emulatorPermissions: RuntimeEmulatorCommands['emulatorPermissions'] = + this.emulatorCommands.emulatorPermissions.bind(this.emulatorCommands) + emulatorAx: RuntimeEmulatorCommands['emulatorAx'] = this.emulatorCommands.emulatorAx.bind( + this.emulatorCommands + ) + emulatorLogcat: RuntimeEmulatorCommands['emulatorLogcat'] = + this.emulatorCommands.emulatorLogcat.bind(this.emulatorCommands) + emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = + this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) + + private getAuthoritativeWindow(): BrowserWindow { + const win = this.getAvailableAuthoritativeWindow() + if (!win || win.isDestroyed()) { + throw new Error('No renderer window available') + } + return win + } + + private getAvailableAuthoritativeWindow(): BrowserWindow | null { + if (this.authoritativeWindowId === null) { + return null + } + const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId) + return win && !win.isDestroyed() ? win : null + } +} + +const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 +// Why: chunks that could complete an actionable prompt bypass the throttle so blocked stamps stay immediate; scanned over the new chunk + short carry, never the whole window. +const WAIT_BLOCKED_KEYWORD_PATTERN = + /press enter|press t to trust|do you trust|trust this|trusted workspace|permission required|requires permission|allow once|allow always|update available|choose working directory|codex just got an upgrade|hooks need review/ +const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 +const MAX_TAIL_LINES = 2000 +const MAX_TAIL_CHARS = 256 * 1024 +const MAX_TAIL_PARTIAL_CHARS = 4000 +const MAX_TAIL_PENDING_ANSI_CHARS = 4096 +const DEFAULT_TERMINAL_READ_LIMIT = 120 +const MAX_TERMINAL_READ_LIMIT = 2000 +const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 +export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000 +const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750 +const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000 +const TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS = 10 +const MAX_PREVIEW_LINES = 6 +const MAX_PREVIEW_CHARS = 300 +const WORKTREE_STATUS_PRIORITY: Record = { + inactive: 0, + active: 1, + done: 2, + working: 3, + permission: 4 +} +const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 +const DEFAULT_TERMINAL_LIST_LIMIT = 200 +const DEFAULT_WORKTREE_LIST_LIMIT = 200 +const DEFAULT_WORKTREE_PS_LIMIT = 200 +const DISCONNECTED_PTY_RECORD_MAX = 128 +const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 +const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 +// Why: agent-scratch repos don't need 30s freshness — the steady-state scan +// fan-out was measured at ~128 git execs/min on real installs, mostly against +// these (crash-cluster diagnostics, 2026-07). +const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 +// Why: the Git-admin fingerprint reads HEAD and its ref tip exactly, but sparse-checkout pattern +// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size +// stamp, so a real scan still runs on this interval even while the probe reports "unchanged". +export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 +// Why reserved rather than spent on the probe: when the probe expires the caller still has to run +// `git worktree list` and answer inside the same budget, so the fallback needs its own room. Sized +// for a healthy Git on a busy host, well above the tens of milliseconds a warm list costs. +const WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS = 1500 +// Why derived from the caller's budget instead of a generous absolute: this wait runs *inside* +// RESOLVED_WORKTREE_REPO_TIMEOUT_MS, so outlasting it buys nothing — the caller has already given up +// and restored persisted rows — while turning a reusable scan into a full-budget stall that repeats +// on every TTL expiry. Subtracting keeps that invariant true by construction if either side moves. +// Why not smaller: the probe reads a subset of what the fallback scan reads, so a probe too slow to +// fit is a scan that will not fit either — waiting is strictly better right up to the budget. +// Expiring yields `null`, the existing "cannot prove unchanged" sentinel, so a real scan runs. +export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = + RESOLVED_WORKTREE_REPO_TIMEOUT_MS - WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS + +export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { + return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) + ? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS + : WORKTREE_SCAN_CACHE_TTL_MS +} +const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 +// Why: the slice of the list budget reserved for the aggregate to collect the providers +// that answered after a stalled one gives up. +const PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS = 500 +// Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out. +const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000 + +async function waitForWorktreeTerminalMutation( + previous: Promise, + deadline?: number +): Promise { + if (deadline === undefined) { + await previous + return + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + throw new Error('terminal_worktree_sleep_timeout') + } + let timeout: ReturnType | undefined + try { + await Promise.race([ + previous, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('terminal_worktree_sleep_timeout')), + remainingMs + ) + }) + ]) + } finally { + if (timeout !== undefined) { + clearTimeout(timeout) + } + } +} + +// Why: listener fan-out is best-effort delivery. One subscriber throwing synchronously — e.g. a +// paired-client relay whose stream is closed — must never abort the emitting operation or leak +// state (a lock/mutation) the caller holds across the emit. Isolate every listener and log. +function notifyRuntimeListeners( + listeners: Iterable, + deliver: (listener: L) => void, + context: string +): void { + for (const listener of listeners) { + try { + deliver(listener) + } catch (error) { + console.error(`[runtime] ${context} listener threw`, error) + } + } +} +// Why (§3.3): 30s freshness window reuses a recent fetch for repeat create/dispatch on the same repo+remote; short enough a changed remote is seen next action. +const FETCH_FRESHNESS_MS = 30_000 +// Why: bound fetches so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation; parity with the exact-base refresh sibling. +const REMOTE_FETCH_TIMEOUT_MS = 60_000 +const REMOTE_FETCH_CACHE_MAX = 512 +const DRIFT_PROBE_SUBJECT_LIMIT = 5 + +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + +function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { + if (!selector?.startsWith('id:')) { + return null + } + const id = selector.slice(3) + return id.length > 0 ? id : null +} + +function withTimeoutResult( + promise: Promise, + timeoutMs: number +): Promise<{ ok: true; value: T } | { ok: false }> { + return withTimeout( + promise.then((value) => ({ ok: true, value }) as const), + timeoutMs, + { + ok: false + } + ) +} + +export function buildPreview(lines: string[], partialLine: string): string { + const previewLines: string[] = [] + const collectVisibleLine = (line: string): void => { + const trimmed = line.trim() + if (trimmed.length > 0) { + previewLines.push(trimmed) + } + } + + if (partialLine.length > 0) { + collectVisibleLine(partialLine) + } + for ( + let index = lines.length - 1; + index >= 0 && previewLines.length < MAX_PREVIEW_LINES; + index-- + ) { + collectVisibleLine(lines[index]) + } + previewLines.reverse() + + const preview = previewLines.join('\n') + return preview.length > MAX_PREVIEW_CHARS + ? preview.slice(preview.length - MAX_PREVIEW_CHARS) + : preview +} + +// Why: restore payloads can be multi-MB; the records only retain a bounded tail, +// so cap the one-time parse on the spawn path to the suffix that can matter. +const MAX_RESTORE_TAIL_SEED_CHARS = 256 * 1024 + +type RestoredTerminalTailSeed = { + lines: string[] + transcriptLines: string[] + transcriptChars: number + partialLine: string + pendingAnsi: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + linesTotal: number + preview: string +} + +type RestorableTerminalTailRecord = Pick< + RuntimePtyWorktreeRecord, + | 'lastOutputAt' + | 'tailBuffer' + | 'tailTranscriptBuffer' + | 'tailTranscriptChars' + | 'tailPartialLine' + | 'tailPendingAnsi' + | 'tailRedrawCursor' + | 'tailTruncated' + | 'tailLinesTotal' + | 'preview' +> + +export function buildRestoredTerminalTailSeed(text: string): RestoredTerminalTailSeed | null { + let bounded = text + let sliced = false + if (bounded.length > MAX_RESTORE_TAIL_SEED_CHARS) { + bounded = bounded.slice(-MAX_RESTORE_TAIL_SEED_CHARS) + // Why: an arbitrary suffix can start mid-escape; restarting after the first + // line break resumes at a boundary (escape params never span \n or \r — + // \r covers newline-free CR-redraw streams). Consume a full \r\n pair so + // the seed does not begin with a phantom blank line. + const anchor = bounded.search(/[\r\n]/) + if (anchor !== -1) { + bounded = bounded.slice( + bounded[anchor] === '\r' && bounded[anchor + 1] === '\n' ? anchor + 2 : anchor + 1 + ) + } + sliced = true + } + // Why: the live-path pipeline, so seeded records equal what streaming the + // same bytes through onPtyData would have produced. + const normalized = normalizeTerminalChunk(bounded) + const tail = appendNormalizedToTailBuffer([], '', normalized.text, null) + if (tail.lines.length === 0 && tail.partialLine.length === 0) { + return null + } + const transcript = appendCompletedTerminalTranscript( + [], + 0, + tail.newlyCompletedLines, + tail.newCompleteLines + ) + return { + lines: tail.lines, + transcriptLines: transcript.lines, + transcriptChars: transcript.characters, + partialLine: tail.partialLine, + pendingAnsi: normalized.pendingAnsi, + redrawCursor: tail.redrawCursor, + truncated: sliced || tail.truncated || transcript.truncated, + linesTotal: tail.newCompleteLines, + preview: buildPreview(tail.lines, tail.partialLine) + } +} + +function restoredTerminalTailSeedAllowed(record: RestorableTerminalTailRecord): boolean { + return ( + record.lastOutputAt === null && + record.preview.length === 0 && + record.tailBuffer.length === 0 && + record.tailPartialLine.length === 0 + ) +} + +// Deliberately untouched: lastOutputAt (historical bytes must not read as fresh +// activity) and waitBlockedAt/tailWaitState (a restored prompt is not a live +// wait signal; the next live chunk recomputes both from this seeded tail). +function applyRestoredTerminalTailSeed( + record: RestorableTerminalTailRecord, + seed: RestoredTerminalTailSeed +): void { + // Why shared instances: append helpers never mutate prior arrays, and equal + // references let tailStateMatches keep its O(1) leaf/pty reuse fast path. + record.tailBuffer = seed.lines + record.tailTranscriptBuffer = seed.transcriptLines + record.tailTranscriptChars = seed.transcriptChars + record.tailPartialLine = seed.partialLine + record.tailPendingAnsi = seed.pendingAnsi + record.tailRedrawCursor = seed.redrawCursor + record.tailTruncated = seed.truncated + record.tailLinesTotal = seed.linesTotal + record.preview = seed.preview +} + +function buildTerminalWaitText(lines: string[], partialLine: string, preview: string): string { + const waitText = buildTailLines(lines, partialLine) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + // Why: the preview is intentionally short, but wait readiness needs the retained tail so ready headers aren't truncated away. + return waitText.length > 0 ? waitText : preview +} + +export type TerminalTailWaitState = { + waitText: string + signal: { reason: RuntimeTerminalWaitBlockedReason; index: number } | null + // Why: preview is only an empty-tail fallback, recomputed each append, so a preview-derived state can't be reused as the next previous state (gated on fromTail). + fromTail: boolean +} + +// Why: runs per PTY chunk (hundreds/sec); only candidate-bearing tails parse the full 256 KiB, and the cached state avoids repeating that work next chunk. +export function computeTerminalTailWaitState( + lines: string[], + partialLine: string, + preview: string +): TerminalTailWaitState { + const tailShape = inspectTerminalWaitTail(lines, partialLine) + if (!tailShape.fromTail) { + return { + waitText: preview, + signal: findActionableTerminalWaitBlockedSignal(preview.toLowerCase()), + fromTail: false + } + } + if (!tailShape.mayContainBlockedSignal) { + // Why: reads waitText only when a signal exists; avoid retaining a rebuilt 256 KiB string in the common case. + return { waitText: '', signal: null, fromTail: true } + } + const tailText = buildTailLines(lines, partialLine) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + const fromTail = tailText.length > 0 + const waitText = fromTail ? tailText : preview + return { + waitText, + signal: findActionableTerminalWaitBlockedSignal(waitText.toLowerCase()), + fromTail + } +} + +function inspectTerminalWaitTail( + lines: string[], + partialLine: string +): { fromTail: boolean; mayContainBlockedSignal: boolean } { + let fromTail = false + let mayContainBlockedSignal = false + for (const line of lines) { + if (!fromTail && line.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + mayContainBlockedSignal = true + } + } + if (!fromTail && partialLine.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { + mayContainBlockedSignal = true + } + return { fromTail, mayContainBlockedSignal } +} + +// Why: consumes precomputed wait states so full-tail scans aren't repeated per chunk (replaces the former inline double full-tail scan). +export function tailGainedNewerBlockedReason( + previous: TerminalTailWaitState, + next: TerminalTailWaitState, + appendedText: string +): boolean { + if (next.signal === null) { + return false + } + // Why: permission prompts can split across PTY chunks; stamp when the tail first becomes blocked, or a later prompt follows stale blocked text. + if (previous.signal === null) { + return true + } + const appendCandidateSignal = findActionableTerminalWaitBlockedSignal( + `${previous.waitText}${appendedText}`.toLowerCase() + ) + return appendCandidateSignal !== null && appendCandidateSignal.index > previous.signal.index +} + +export function appendNormalizedToTailBuffer( + previousLines: string[], + previousPartialLine: string, + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null = null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + if (normalizedChunk.length === 0) { + return { + lines: previousLines, + partialLine: previousPartialLine, + redrawCursor: previousRedrawCursor, + truncated: false, + newCompleteLines: 0, + newlyCompletedLines: [] + } + } + + // Why: fullscreen TUIs emit long newline-free redraw streams; keep the line transcript for pagination but bound partial-line work. + const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS + const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + const combinedChunk = `${boundedPreviousPartialLine}${normalizedChunk}` + if (previousRedrawCursor || containsTerminalVerticalLineControl(combinedChunk)) { + return appendNormalizedToMultilineTailBuffer( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + } + + // Why: status UIs redraw one line via CR/backspace/erase; retain the latest redraw segment instead of appending every spinner frame. + const segments = splitRetainedTerminalTailSegments(combinedChunk) + const pieces = processTerminalTailCompleteSegments(segments.completeSegments) + const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) + const partialResult = applyTerminalLineControls(segments.partialSegment) + const nextPartialLine = trimTerminalLineRight(partialResult.text) + const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + const newCompleteLines = segments.completeLineCount + const omittedNewCompleteLines = newCompleteLines - pieces.length + let nextLines = + newCompleteLines > 0 + ? [...(omittedNewCompleteLines > 0 ? [] : previousLines), ...newlyCompletedLines] + : previousLines + let truncated = + previousPartialWasCapped || + omittedNewCompleteLines > 0 || + nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS + + if (nextLines.length > MAX_TAIL_LINES) { + nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) + truncated = true + } + + if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) { + if (nextLines === previousLines) { + nextLines = [...previousLines] + } + let totalChars = + nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length + let trimStartIndex = 0 + while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= nextLines[trimStartIndex].length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + nextLines = nextLines.slice(trimStartIndex) + truncated = true + } + } + + const redrawCursor = + !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length + ? null + : { + rowFromEnd: 0, + column: partialResult.cursorColumn + } + + return { + lines: nextLines, + partialLine: retainedPartialLine, + redrawCursor, + truncated, + newCompleteLines, + newlyCompletedLines + } +} + +function trimTerminalLineRight(line: string): string { + let end = line.length + while (end > 0) { + const code = line.charCodeAt(end - 1) + if (code !== 0x20 && code !== 0x09) { + break + } + end -= 1 + } + return end === line.length ? line : line.slice(0, end) +} + +// Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts. +const REDRAW_WINDOW_SAFETY_ROWS = 8 + +function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g + let match: RegExpExecArray | null + while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + +function appendNormalizedToMultilineTailBuffer( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + const windowRows = + maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + if (windowRows >= previousLines.length) { + return appendNormalizedToMultilineTailBufferUnwindowed( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + } + const prefixLength = previousLines.length - windowRows + const suffix = previousLines.slice(prefixLength) + const windowed = appendNormalizedToMultilineTailBufferUnwindowed( + suffix, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + let lines = previousLines.slice(0, prefixLength) + // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + lines[index] = line.replace(/[ \t]+$/g, '') + } + } + for (const line of windowed.lines) { + lines.push(line) + } + let truncated = windowed.truncated + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + let totalChars = windowed.partialLine.length + for (const line of lines) { + totalChars += line.length + } + let dropCount = 0 + while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= lines[dropCount]!.length + dropCount += 1 + } + if (dropCount > 0) { + lines = lines.slice(dropCount) + truncated = true + } + return { + lines, + partialLine: windowed.partialLine, + redrawCursor: windowed.redrawCursor, + truncated, + newCompleteLines: windowed.newCompleteLines, + newlyCompletedLines: windowed.newlyCompletedLines + } +} + +export function appendNormalizedToMultilineTailBufferUnwindowed( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + const rows: RetainedTerminalRow[] = [ + ...previousLines.map((line) => ({ text: line, completed: true })), + { text: boundedPreviousPartialLine, completed: false } + ] + let cursorRow = previousRedrawCursor + ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) + : rows.length - 1 + let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length + let newCompleteLines = 0 + const newlyCompletedLines: string[] = [] + let newlyCompletedLineCharacters = 0 + let newlyCompletedLineStart = 0 + let truncated = previousPartialWasCapped + + const retainNewlyCompletedLine = (line: string): void => { + newlyCompletedLines.push(line) + newlyCompletedLineCharacters += line.length + while ( + newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || + newlyCompletedLineCharacters > MAX_TAIL_CHARS + ) { + newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length + newlyCompletedLineStart += 1 + } + // Why: a single PTY chunk can carry unbounded newlines; compact in batches while retaining the suffix needed for stable pagination. + if (newlyCompletedLineStart >= MAX_TAIL_LINES) { + newlyCompletedLines.splice(0, newlyCompletedLineStart) + newlyCompletedLineStart = 0 + } + } + + const ensureCursorRow = (): void => { + while (cursorRow >= rows.length) { + rows.push({ text: '', completed: false }) + } + } + const trimRows = (): void => { + const maxRows = MAX_TAIL_LINES + 1 + if (rows.length <= maxRows) { + return + } + const removeCount = rows.length - maxRows + rows.splice(0, removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + const moveCursorToColumn = (nextColumn: number): void => { + cursorColumn = clampTerminalPreviewCursor(nextColumn) + } + const markCursorRowRewritten = (): void => { + ensureCursorRow() + rows[cursorRow]!.completed = false + } + const writeChar = (char: string): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (cursorColumn > row.text.length) { + row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` + } + row.text = + cursorColumn >= row.text.length + ? `${row.text}${char}` + : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` + cursorColumn += 1 + } + const eraseLine = (mode: number): void => { + ensureCursorRow() + markCursorRowRewritten() + const row = rows[cursorRow]! + if (mode === 0) { + row.text = row.text.slice(0, cursorColumn) + } else if (mode === 1) { + const deleteCount = Math.min(cursorColumn + 1, row.text.length) + row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` + } else if (mode === 2) { + row.text = '' + } + } + + for (let index = 0; index < normalizedChunk.length; index += 1) { + const char = normalizedChunk[index] + if (char === '\n') { + ensureCursorRow() + rows[cursorRow]!.completed = true + newCompleteLines += 1 + retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) + cursorRow += 1 + cursorColumn = 0 + ensureCursorRow() + trimRows() + continue + } + if (char === '\r') { + cursorColumn = 0 + continue + } + if (char === '\u0008') { + cursorColumn = Math.max(0, cursorColumn - 1) + continue + } + if (char === '\u001b') { + const parsed = parseAnsiControlSequence(normalizedChunk, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { + continue + } + const firstParam = parsed.firstParam ?? 1 + if (parsed.final === 'A') { + cursorRow = Math.max(0, cursorRow - firstParam) + rows.splice(cursorRow + 1) + } else if (parsed.final === 'K') { + eraseLine(parsed.firstParam ?? 0) + } else if (parsed.final === 'G' || parsed.final === '`') { + moveCursorToColumn(firstParam - 1) + } else if (parsed.final === 'D') { + cursorColumn = Math.max(0, cursorColumn - firstParam) + } else if (parsed.final === 'C') { + moveCursorToColumn(cursorColumn + firstParam) + } + continue + } + writeChar(char) + } + + return finalizeRetainedTerminalRows( + rows, + cursorRow, + cursorColumn, + truncated, + newCompleteLines, + newlyCompletedLineStart > 0 + ? newlyCompletedLines.slice(newlyCompletedLineStart) + : newlyCompletedLines + ) +} + +type RetainedTailRedrawCursor = { + rowFromEnd: number + column: number +} + +type RetainedTerminalRow = { + text: string + completed: boolean +} + +function finalizeRetainedTerminalRows( + rows: RetainedTerminalRow[], + cursorRow: number, + cursorColumn: number, + initialTruncated: boolean, + newCompleteLines: number, + newlyCompletedLines: string[] +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number + newlyCompletedLines: string[] +} { + let truncated = initialTruncated + let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) + + if (retainedRows.length > MAX_TAIL_LINES + 1) { + const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) + retainedRows = retainedRows.slice(removeCount) + cursorRow = Math.max(0, cursorRow - removeCount) + truncated = true + } + + let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) + let trimStartIndex = 0 + while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { + totalChars -= retainedRows[trimStartIndex]!.text.length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + retainedRows = retainedRows.slice(trimStartIndex) + cursorRow = Math.max(0, cursorRow - trimStartIndex) + truncated = true + } + while ( + retainedRows.length > 1 && + cursorRow < retainedRows.length - 1 && + retainedRows.at(-1)?.completed === false && + retainedRows.at(-1)?.text.length === 0 + ) { + retainedRows.pop() + } + + const lastRow = retainedRows.at(-1) + let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' + let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( + (row) => row.text + ) + + if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { + partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) + truncated = true + } + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + const outputRowCount = lines.length + 1 + const defaultCursorRow = outputRowCount - 1 + const defaultCursorColumn = partialLine.length + const redrawCursor = + cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn + ? null + : { + rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), + column: clampTerminalPreviewCursor(cursorColumn) + } + + return { + lines, + partialLine, + redrawCursor, + truncated, + newCompleteLines, + newlyCompletedLines + } +} + +function splitRetainedTerminalTailSegments(value: string): { + completeSegments: string[] + partialSegment: string + completeLineCount: number +} { + let completeLineCount = 0 + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '\n') { + completeLineCount += 1 + } + } + + const retainedCompleteCount = Math.min(completeLineCount, MAX_TAIL_LINES) + const omittedCompleteCount = completeLineCount - retainedCompleteCount + let startIndex = 0 + if (omittedCompleteCount > 0) { + let seen = 0 + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== '\n') { + continue + } + seen += 1 + if (seen === omittedCompleteCount) { + startIndex = index + 1 + break + } + } + } + + const completeSegments: string[] = [] + let segmentStart = startIndex + for (let index = startIndex; index < value.length; index += 1) { + if (value[index] !== '\n') { + continue + } + completeSegments.push(value.slice(segmentStart, index)) + segmentStart = index + 1 + } + + return { + completeSegments, + partialSegment: value.slice(segmentStart), + completeLineCount + } +} + +function processTerminalTailCompleteSegments(segments: string[]): string[] { + const processed: string[] = [] + let totalChars = 0 + for (let index = segments.length - 1; index >= 0; index -= 1) { + const line = applyTerminalLineControls(segments[index]!).text + processed.push(line) + totalChars += line.length + if (totalChars > MAX_TAIL_CHARS) { + break + } + } + processed.reverse() + return processed +} + +function applyTerminalLineControls(line: string): { + text: string + cursorColumn: number + hadControl: boolean +} { + const carriageIndex = line.lastIndexOf('\r') + const latestRedraw = carriageIndex !== -1 ? line.slice(carriageIndex + 1) : line + if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { + return { + text: latestRedraw, + cursorColumn: latestRedraw.length, + hadControl: carriageIndex !== -1 + } + } + + const chars: string[] = [] + let cursor = 0 + const moveCursorTo = (nextCursor: number): void => { + cursor = clampTerminalPreviewCursor(nextCursor) + } + const writeChar = (char: string): void => { + if (cursor > chars.length) { + const oldLength = chars.length + chars.length = cursor + chars.fill(' ', oldLength, cursor) + } + if (cursor >= chars.length) { + chars.push(char) + } else { + chars[cursor] = char + } + cursor += 1 + } + for (let index = 0; index < latestRedraw.length; index += 1) { + const char = latestRedraw[index] + if (char === '\u0008') { + if (cursor > 0) { + cursor -= 1 + } + } else if (char === '\u001b') { + const parsed = parseAnsiControlSequence(latestRedraw, index) + if (!parsed) { + continue + } + index = parsed.endIndex + if (parsed.kind !== 'csi') { + continue + } + if (!hasCanonicalNumericCsiParams(parsed.params)) { + continue + } + if (parsed.final === 'K') { + const mode = parsed.firstParam ?? 0 + if (mode === 0) { + chars.length = cursor + } else if (mode === 1) { + const deleteCount = Math.min(cursor + 1, chars.length) + chars.fill(' ', 0, deleteCount) + } else if (mode === 2) { + chars.length = 0 + } + } else if (parsed.final === 'G' || parsed.final === '`') { + moveCursorTo((parsed.firstParam ?? 1) - 1) + } else if (parsed.final === 'D') { + cursor = Math.max(0, cursor - (parsed.firstParam ?? 1)) + } else if (parsed.final === 'C') { + moveCursorTo(cursor + (parsed.firstParam ?? 1)) + } + } else { + writeChar(char) + } + } + return { text: chars.join(''), cursorColumn: cursor, hadControl: true } +} + +function clampTerminalPreviewCursor(nextCursor: number): number { + if (!Number.isFinite(nextCursor)) { + return MAX_TAIL_PARTIAL_CHARS + } + return Math.min(MAX_TAIL_PARTIAL_CHARS, Math.max(0, Math.floor(nextCursor))) +} + +function parseAnsiControlSequence( + value: string, + escapeIndex: number +): + | { kind: 'csi'; final: string; params: string; firstParam: number | null; endIndex: number } + | { + kind: 'other' + endIndex: number + } + | null { + const introducer = value[escapeIndex + 1] + if (introducer === '[') { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code < 0x40 || code > 0x7e) { + continue + } + const params = value.slice(escapeIndex + 2, index) + const firstParamMatch = /^(\d+)/.exec(params) + return { + kind: 'csi', + final: value[index] ?? '', + params, + firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, + endIndex: index + } + } + return null + } + if (introducer === ']') { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + if (value[index] === '\u0007') { + return { kind: 'other', endIndex: index } + } + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return { kind: 'other', endIndex: index + 1 } + } + } + return null + } + if (isStTerminatedStringControlIntroducer(introducer)) { + for (let index = escapeIndex + 2; index < value.length; index += 1) { + if (value[index] === '\u001b' && value[index + 1] === '\\') { + return { kind: 'other', endIndex: index + 1 } + } + } + return null + } + return { kind: 'other', endIndex: escapeIndex + 1 } +} + +function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean { + return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' +} + +function hasCanonicalNumericCsiParams(params: string): boolean { + return /^[0-9;]*$/.test(params) +} + +function containsTerminalVerticalLineControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== '\u001b') { + continue + } + const parsed = parseAnsiControlSequence(value, index) + if (!parsed) { + return false + } + index = parsed.endIndex + if ( + parsed.kind === 'csi' && + parsed.final === 'A' && + hasCanonicalNumericCsiParams(parsed.params) + ) { + return true + } + } + return false +} + +function appendCompletedTerminalTranscript( + previousLines: string[], + previousCharacters: number, + newlyCompletedLines: string[], + newCompleteLineCount: number +): { lines: string[]; characters: number; truncated: boolean } { + if (newCompleteLineCount === 0) { + return { lines: previousLines, characters: previousCharacters, truncated: false } + } + + const omittedNewLineCount = Math.max(0, newCompleteLineCount - newlyCompletedLines.length) + const lines = omittedNewLineCount > 0 ? [] : [...previousLines] + let characters = omittedNewLineCount > 0 ? 0 : previousCharacters + for (const line of newlyCompletedLines) { + lines.push(line) + characters += line.length + } + + let dropCount = Math.max(0, lines.length - MAX_TAIL_LINES) + for (let index = 0; index < dropCount; index += 1) { + characters -= lines[index]!.length + } + while (dropCount < lines.length && characters > MAX_TAIL_CHARS) { + characters -= lines[dropCount]!.length + dropCount += 1 + } + + return { + lines: dropCount > 0 ? lines.slice(dropCount) : lines, + characters, + truncated: omittedNewLineCount > 0 || dropCount > 0 + } +} + +function tailStateMatches( + lines: string[], + transcriptLines: string[], + partialLine: string, + pendingAnsi: string, + redrawCursor: RetainedTailRedrawCursor | null, + truncated: boolean, + linesTotal: number, + snapshot: { + lines: string[] + transcriptLines: string[] + partialLine: string + pendingAnsi: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + linesTotal: number + } +): boolean { + if ( + partialLine !== snapshot.partialLine || + pendingAnsi !== snapshot.pendingAnsi || + !tailRedrawCursorsMatch(redrawCursor, snapshot.redrawCursor) || + truncated !== snapshot.truncated || + linesTotal !== snapshot.linesTotal || + lines.length !== snapshot.lines.length || + transcriptLines.length !== snapshot.transcriptLines.length + ) { + return false + } + if (lines === snapshot.lines) { + return true + } + for (let index = 0; index < lines.length; index++) { + if (lines[index] !== snapshot.lines[index]) { + return false + } + } + if (transcriptLines !== snapshot.transcriptLines) { + for (let index = 0; index < transcriptLines.length; index++) { + if (transcriptLines[index] !== snapshot.transcriptLines[index]) { + return false + } + } + } + return true +} + +function tailRedrawCursorsMatch( + left: RetainedTailRedrawCursor | null, + right: RetainedTailRedrawCursor | null +): boolean { + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return left.rowFromEnd === right.rowFromEnd && left.column === right.column +} + +function buildTailLines(lines: string[], partialLine: string): string[] { + return partialLine.length > 0 ? [...lines, partialLine] : lines +} + +function terminalReadLimit(limit: number | undefined, defaultLimit: number): number { + if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { + return defaultLimit + } + return Math.min(Math.max(1, Math.floor(limit)), MAX_TERMINAL_READ_LIMIT) +} + +function trimTerminalPreviewToCharacterBudget( + lines: string[], + characterBudget: number +): { tail: string[]; limited: boolean; omittedLineCount: number; slicedFirstLine: boolean } { + let totalCharacters = lines.reduce((sum, line) => sum + line.length, 0) + if (totalCharacters <= characterBudget) { + return { tail: lines, limited: false, omittedLineCount: 0, slicedFirstLine: false } + } + + let omittedLineCount = 0 + while ( + omittedLineCount < lines.length && + totalCharacters - lines[omittedLineCount].length >= characterBudget + ) { + totalCharacters -= lines[omittedLineCount].length + omittedLineCount += 1 + } + const tail = omittedLineCount > 0 ? lines.slice(omittedLineCount) : [...lines] + + let slicedFirstLine = false + if (tail.length > 0 && totalCharacters > characterBudget) { + tail[0] = tail[0].slice(totalCharacters - characterBudget) + slicedFirstLine = true + } + + return { tail, limited: true, omittedLineCount, slicedFirstLine } +} + +function readTerminalTail(args: { + handle: string + status: RuntimeTerminalState + previewLines: string[] + completedLines: string[] + partialLine: string + completedLineCount: number + bufferTruncated: boolean + cursor?: number + limit?: number +}): RuntimeTerminalRead { + const oldestCursor = Math.max(0, args.completedLineCount - args.completedLines.length) + const latestCursor = args.completedLineCount + + if (typeof args.cursor === 'number' && args.cursor >= 0) { + const limit = terminalReadLimit(args.limit, MAX_TERMINAL_READ_LIMIT) + if (args.cursor > latestCursor) { + return { + handle: args.handle, + status: args.status, + tail: [], + truncated: false, + limited: false, + oldestCursor: String(oldestCursor), + nextCursor: String(latestCursor), + latestCursor: String(latestCursor), + returnedLineCount: 0 + } + } + // Why: cursor reads return completed lines only, so a partial isn't delivered once as "hel" then again as "hello" after the newline. + const startCursor = Math.max(args.cursor, oldestCursor) + const startIndex = startCursor - oldestCursor + const available = args.completedLines.slice(startIndex) + const tail = available.slice(0, limit) + const nextCursor = startCursor + tail.length + return { + handle: args.handle, + status: args.status, + tail, + truncated: args.cursor < oldestCursor, + limited: tail.length < available.length, + oldestCursor: String(oldestCursor), + nextCursor: String(nextCursor), + latestCursor: String(latestCursor), + returnedLineCount: tail.length + } + } + + // Why: un-cursored reads are preview reads — return the latest bounded view; the larger buffer stays available via cursor reads + --limit. + const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT) + const allLines = buildTailLines(args.previewLines, args.partialLine) + const lineBoundedTail = allLines.slice(-limit) + const charBoundedTail = trimTerminalPreviewToCharacterBudget( + lineBoundedTail, + MAX_TERMINAL_PREVIEW_CHARS + ) + const lineBoundedStartIndex = Math.max(0, allLines.length - lineBoundedTail.length) + const charBoundedStartIndex = lineBoundedStartIndex + charBoundedTail.omittedLineCount + const hasPageableOmittedCompletedLines = + Math.min(args.completedLineCount, charBoundedStartIndex) > 0 || + (charBoundedTail.slicedFirstLine && charBoundedStartIndex < args.completedLineCount) + // Why: a long partial line trimmed by the char budget can't be recovered via nextCursor, since cursor reads only page completed lines. + const truncatedByNonPageablePartial = charBoundedTail.limited && !hasPageableOmittedCompletedLines + return { + handle: args.handle, + status: args.status, + tail: charBoundedTail.tail, + truncated: args.bufferTruncated || truncatedByNonPageablePartial, + limited: lineBoundedTail.length < allLines.length || charBoundedTail.limited, + oldestCursor: String(oldestCursor), + nextCursor: String(latestCursor), + latestCursor: String(latestCursor), + returnedLineCount: charBoundedTail.tail.length + } +} + +function shouldFallbackToVisibleTerminalSnapshot( + read: RuntimeTerminalRead, + opts: { cursor?: number; limit?: number } +): boolean { + if (typeof opts.cursor === 'number') { + return false + } + if (read.tail.length === 0) { + return false + } + const hasSubstantialBlankTail = + read.limited === true || read.truncated || read.tail.length >= DEFAULT_TERMINAL_READ_LIMIT + return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0) +} + +function visibleNonBlankTerminalLines(lines: string[]): string[] { + return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) +} + +function projectVisibleTerminalLines(emulator: HeadlessEmulator): { + lines: string[] + draft?: string +} { + const lines = emulator.getVisibleLines() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft) { + lines[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + lines[row] = '' + } + } + return { + lines: visibleNonBlankTerminalLines(lines), + ...(draft ? { draft: draft.text } : {}) + } +} + +export function projectTerminalTailLines( + emulator: HeadlessEmulator, + limit: number +): RuntimeTerminalProjection { + const tail = emulator.getBufferTailLines(limit) + const visible = emulator.getVisibleLines() + const visibleRange = emulator.getVisibleBufferRange() + const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) + if (draft && visibleRange.endExclusive === visibleRange.totalLength) { + visible[draft.promptRow] = draft.promptGlyph + for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { + visible[row] = '' + } + const scrollbackTail = tail.slice(0, Math.max(0, tail.length - visible.length)) + tail.splice(0, tail.length, ...scrollbackTail, ...visibleNonBlankTerminalLines(visible)) + } + return { + lines: visibleNonBlankTerminalLines(tail).slice(-limit), + ...(draft ? { draft: draft.text } : {}) + } +} + +// Why: every read carries its source, so a caller that asked for a screen and got a response +// with no source at all knows it reached a host that predates screen reads — rather than +// mistaking the stream for the screen. Rendered lines only ever enter a read through +// buildVisibleSnapshotReadFallback, which stamps `screen` itself, so anything still unlabelled +// here is the accumulated stream. +function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminalRead { + return resolved.source ? resolved : { ...resolved, source: 'stream' } +} + +function buildVisibleSnapshotReadFallback( + read: RuntimeTerminalRead, + visibleLines: string[], + limit: number | undefined, + draft?: string +): RuntimeTerminalRead { + const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) + const lineBoundedTail = visibleLines.slice(-lineLimit) + const charBoundedTail = trimTerminalPreviewToCharacterBudget( + lineBoundedTail, + MAX_TERMINAL_PREVIEW_CHARS + ) + return { + ...read, + tail: charBoundedTail.tail, + limited: + read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, + returnedLineCount: charBoundedTail.tail.length, + source: 'screen', + ...(draft ? { draft } : {}) + } +} + +function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { + if (leaf.connected) { + return 'running' + } + if (leaf.lastExitCode !== null) { + return 'exited' + } + return 'unknown' +} + +function buildSendPayload(action: { + text?: string + enter?: boolean + interrupt?: boolean +}): string | null { + let payload = '' + if (typeof action.text === 'string' && action.text.length > 0) { + payload += action.text + } + if (action.enter) { + payload += '\r' + } + if (action.interrupt) { + payload += '\x03' + } + return payload.length > 0 ? payload : null +} + +async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise { + if (!text) { + return + } + if (await isTerminalInputTooLargeWithYield(text)) { + throw new Error(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + +// Why: tui-idle needs OSC title transitions; an unsupported CLI/plain shell never fires one, so cap at 5min to avoid indefinite hangs. +const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 +const TUI_IDLE_POLL_INTERVAL_MS = 2000 +const TUI_IDLE_QUIESCENCE_MS = 3000 +const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i +const CLAUDE_IDLE_PREFIX = '\u2733' +const GEMINI_IDLE_PREFIX = '\u25c7' +const PI_IDLE_PREFIX = '\u03c0 - ' + +// Clamp for mobileAutoRestoreFitMs: floor above the legacy 300ms debounce, 1h ceiling (a held PTY beyond that is "I forgot", not intentional). +const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000 +const MOBILE_AUTO_RESTORE_FIT_MAX_MS = 60 * 60 * 1000 + +function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { + const status = detectAgentStatusFromTitle(title) + if (status !== 'idle') { + return null + } + // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. + if ( + EXPLICIT_IDLE_TITLE_RE.test(title) || + // Why: unblock hookless remote waits; guarded writes corroborate this marker. + isOpenCodeNativeTitle(title) || + title.startsWith(CLAUDE_IDLE_PREFIX) || + title.startsWith('* ') || + title.includes(GEMINI_IDLE_PREFIX) || + title.startsWith(PI_IDLE_PREFIX) + ) { + return 'idle' + } + return null +} + +function isKnownReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findKnownReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal !== null && blockedSignal.index > readyIndex) { + return false + } + return true +} + +function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { + const normalized = preview.toLowerCase() + return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null +} + +function findActionableTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal === null) { + return null + } + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) + // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findKnownReadyPromptIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + +function findCodexReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return null + } + const readySegment = normalized.slice(headerIndex) + // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. + return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null +} + +function findAntigravityReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + if (headerIndex === -1) { + return null + } + let lineStart = headerIndex + let modelIndex: number | null = null + let promptIndex: number | null = null + + // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. + for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { + if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { + continue + } + let trimmedStart = lineStart + let trimmedEnd = cursor + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (lineStart > headerIndex && trimmedStart < trimmedEnd) { + if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { + modelIndex = trimmedStart + } + if ( + promptIndex === null && + trimmedEnd - trimmedStart === 1 && + normalized.charCodeAt(trimmedStart) === 62 + ) { + promptIndex = trimmedStart + } + } + lineStart = cursor + 1 + } + + return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null +} + +function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + +const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + +// Why text at all: cursor-agent's hook set has no approval event and beforeShellExecution +// fires for auto-allowed commands too, so the menu is the only authority. Match the key-bound +// choices rather than the prose above them. +const CURSOR_APPROVAL_CHOICE_MARKERS = [ + 'run (once)', + 'to allowlist?', + 'run everything', + 'skip & tell the agent' +] +// Why bounded to the last lines: an answered menu stays in scrollback, and a stale hit fails +// tui-idle and refuses prompt injection. Only a dialog that still owns the bottom of the +// screen is live, and confining the whole match to that window also keeps prose above or +// below — an agent narrating "I'll pick Run Everything" — from anchoring it. +const CURSOR_APPROVAL_TAIL_LINES = 8 + +function findCursorApprovalPromptIndex(normalized: string): number | null { + const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) + const tail = normalized.slice(windowStart) + if (!tail.includes('run this command?')) { + return null + } + const lines = tail.split('\n') + while (lines.length > 0 && lines.at(-1)?.trim() === '') { + lines.pop() + } + let matchedLines = 0 + let lastChoiceLine = -1 + for (let index = 0; index < lines.length; index += 1) { + if (!isCursorApprovalChoiceLine(lines[index])) { + continue + } + matchedLines += 1 + lastChoiceLine = index + } + if (matchedLines < 2) { + return null + } + // Why no slack: every capture of a live dialog ends on its last choice, and one line of + // tolerance is enough for the agent's own narration of a choice to revive an answered menu. + // A redraw caught mid-flight reads as no wait until the next poll, which is the safe way + // to be wrong. + return lastChoiceLine === lines.length - 1 + ? windowStart + tail.lastIndexOf('run this command?') + : null +} + +// Why the trailing key and not the wording alone: an agent narrating "next time I'll suggest +// Run Everything" writes the same words as the menu. A selectable row ends in the key that +// picks it, and prose does not. Spelled as key names rather than a character class, because +// any lowercase run would readmit "…suggest Run Everything (as before)". +const CURSOR_APPROVAL_CHOICE_KEY_RE = + /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ + +function isCursorApprovalChoiceLine(line: string): boolean { + return ( + CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && + CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) + ) +} + +/** Offset of the first character of the last `count` newline-separated lines. */ +function startOfLastLines(value: string, count: number): number { + let cursor = value.length + for (let seen = 0; seen < count; seen += 1) { + const previous = value.lastIndexOf('\n', cursor - 1) + if (previous === -1) { + return 0 + } + cursor = previous + } + return cursor + 1 +} + +/** Why a spread and not `agentWait: value`: an absent key is the only way to say the pane was + * never evaluated, which a reader must not confuse with an evaluated "no wait". */ +function expandTerminalInteractiveWait( + agentWait: RuntimeTerminalInteractiveWait | null | undefined +): { agentWait?: RuntimeTerminalInteractiveWait | null } { + return agentWait === undefined ? {} : { agentWait } +} + +/** A wedged PTY controller must not stall every reader of this pane. */ +const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000 + +function findTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = normalized.lastIndexOf('update available') + if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { + candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') + if ( + modelMigrationIndex !== -1 && + normalized.includes('press enter to continue', modelMigrationIndex) + ) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = normalized.lastIndexOf('hooks need review') + if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { + candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) + } + const trustIndex = Math.max( + normalized.lastIndexOf('do you trust'), + normalized.lastIndexOf('trust this'), + normalized.lastIndexOf('trusted workspace') + ) + const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) + if ( + trustIndex !== -1 && + (trustSegment.includes('workspace') || + trustSegment.includes('folder') || + trustSegment.includes('directory') || + trustSegment.includes('repo')) + ) { + candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + } + const interactivePromptIndex = Math.max( + normalized.lastIndexOf('press enter to confirm'), + normalized.lastIndexOf('press enter to continue'), + normalized.lastIndexOf('press enter to view'), + normalized.lastIndexOf('press enter to insert'), + normalized.lastIndexOf('press t to trust') + ) + const interactivePromptContext = + interactivePromptIndex === -1 + ? '' + : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) + const hasCodexInteractiveContext = + interactivePromptContext.includes('codex') || + interactivePromptContext.includes('permission') || + interactivePromptContext.includes('sandbox') || + interactivePromptContext.includes('trust') || + interactivePromptContext.includes('hook') + if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + const contextStart = Math.max(0, interactivePromptIndex - 600) + const hasSpecificPromptInContext = candidates.some( + (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex + ) + if (!hasSpecificPromptInContext) { + candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) + } + } + const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) + if (cursorApprovalIndex !== null) { + candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex }) + } + const permissionPromptIndex = Math.max( + normalized.lastIndexOf('permission required'), + normalized.lastIndexOf('requires permission') + ) + if (permissionPromptIndex !== -1) { + const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500) + const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) => + permissionSegment.includes(choice) + ).length + if (decisionCount >= 2) { + // Why: preserve the existing remote receipt value for mixed-version clients. + candidates.push({ reason: 'codex-interactive-prompt', index: permissionPromptIndex }) + } + } + return candidates.length > 0 + ? candidates.reduce((latest, candidate) => + candidate.index > latest.index ? candidate : latest + ) + : null +} + +function buildTerminalWaitResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + leaf: RuntimeLeafRecord +): RuntimeTerminalWait { + return buildTerminalWait( + handle, + condition, + getTerminalState(leaf), + leaf.lastExitCode, + undefined, + leaf.lastExitCause + ) +} + +function buildTerminalWaitBlockedResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + leaf: RuntimeLeafRecord, + blockedReason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWait { + return buildTerminalWait( + handle, + condition, + getTerminalState(leaf), + leaf.lastExitCode, + blockedReason, + leaf.lastExitCause + ) +} + +function buildPtyTerminalWaitResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + pty: RuntimePtyWorktreeRecord +): RuntimeTerminalWait { + return buildTerminalWait( + handle, + condition, + getPtyTerminalState(pty), + pty.lastExitCode, + undefined, + pty.lastExitCause + ) +} + +function buildPtyTerminalWaitBlockedResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + pty: RuntimePtyWorktreeRecord, + blockedReason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWait { + return buildTerminalWait( + handle, + condition, + getPtyTerminalState(pty), + pty.lastExitCode, + blockedReason, + pty.lastExitCause + ) +} + +function buildTerminalWait( + handle: string, + condition: RuntimeTerminalWaitCondition, + status: RuntimeTerminalState, + exitCode: number | null, + blockedReason?: RuntimeTerminalWaitBlockedReason, + exitCause?: TerminalExitCause | null +): RuntimeTerminalWait { + return { + handle, + condition, + satisfied: blockedReason === undefined, + status, + exitCode, + ...(exitCause ? { exitCause } : {}), + ...(blockedReason ? { blockedReason } : {}) + } +} + +function getPtyTerminalState(pty: RuntimePtyWorktreeRecord): RuntimeTerminalState { + return pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown' +} + +function branchSelectorMatches(branch: string, selector: string): boolean { + // Why: Git can report a local branch as `refs/heads/foo` or `foo` depending on the plumbing path; accept either. + return normalizeLocalBranchName(branch) === normalizeLocalBranchName(selector) +} + +function runtimePathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +function runtimeWorktreeIdentityKey(worktreeId: string): string { + // Same suffix rule: this keys PTY refresh, sleep, and mutation-queue state per session. + const parsed = splitWorktreeId(worktreeId) + return parsed + ? `${parsed.repoId}\0${normalizeRuntimePathForComparison(parsed.worktreePath)}` + : worktreeId +} + +function runtimeWorktreeLookupKey(worktreeId: string): string { + const parsed = splitWorktreeId(worktreeId) + return JSON.stringify( + parsed + ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] + : ['raw', worktreeId] + ) +} + +function createIncrementalResolvedWorktreeLookup( + resolvedWorktrees: ResolvedWorktree[] +): (worktreeId: string) => ResolvedWorktree | undefined { + const worktreeByIdentity = new Map() + let indexedCount = 0 + return (worktreeId) => { + const lookupKey = runtimeWorktreeLookupKey(worktreeId) + const indexed = worktreeByIdentity.get(lookupKey) + if (indexed) { + return indexed + } + while (indexedCount < resolvedWorktrees.length) { + const worktree = resolvedWorktrees[indexedCount] + indexedCount += 1 + const key = runtimeWorktreeLookupKey(worktree.id) + // Why: preserve Array.find's first match when normalized identities collide. + if (!worktreeByIdentity.has(key)) { + worktreeByIdentity.set(key, worktree) + } + if (key === lookupKey) { + return worktreeByIdentity.get(key) + } + } + return undefined + } +} + +function resolveTerminalSessionWorktreeId( + session: WorkspaceSessionState, + targetWorktreeId: string +): string | null { + const keyedWorktreeIds = new Set([ + ...Object.keys(session.tabsByWorktree), + ...Object.keys(session.tabGroups ?? {}), + ...Object.keys(session.tabGroupLayouts ?? {}), + ...Object.keys(session.activeTabIdByWorktree ?? {}), + ...Object.keys(session.activeGroupIdByWorktree ?? {}) + ]) + const matches = [...keyedWorktreeIds].filter((worktreeId) => + worktreeIdsEqual(worktreeId, targetWorktreeId) + ) + return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId) +} + +function canonicalizeTerminalSessionWorktreeId( + session: WorkspaceSessionState, + sourceWorktreeId: string, + targetWorktreeId: string +): void { + if (sourceWorktreeId === targetWorktreeId) { + return + } + const tabs = session.tabsByWorktree[sourceWorktreeId] ?? [] + delete session.tabsByWorktree[sourceWorktreeId] + session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({ + ...tab, + worktreeId: targetWorktreeId + })) + + const groups = session.tabGroups?.[sourceWorktreeId] + if (groups) { + delete session.tabGroups![sourceWorktreeId] + session.tabGroups![targetWorktreeId] = groups.map((group) => ({ + ...group, + worktreeId: targetWorktreeId + })) + } + for (const keyedState of [ + session.tabGroupLayouts, + session.activeTabIdByWorktree, + session.activeGroupIdByWorktree + ]) { + if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) { + continue + } + keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never + delete keyedState[sourceWorktreeId] + } +} + +function inferWorktreeIdFromPtyId(ptyId: string): string | null { + return parsePtySessionId(ptyId).worktreeId +} + +function indexPersistedPtyWorktreeBindings( + session: WorkspaceSessionState | null | undefined +): ReadonlyMap { + const worktreeIdByPtyId = new Map() + const ambiguousPtyIds = new Set() + const bind = (ptyId: string | null | undefined, worktreeId: string): void => { + if (!ptyId || ambiguousPtyIds.has(ptyId)) { + return + } + const existingWorktreeId = worktreeIdByPtyId.get(ptyId) + if (existingWorktreeId && existingWorktreeId !== worktreeId) { + // Why: a corrupt/stale duplicate binding must not attribute a live PTY to whichever workspace was visited first. + worktreeIdByPtyId.delete(ptyId) + ambiguousPtyIds.add(ptyId) + return + } + worktreeIdByPtyId.set(ptyId, worktreeId) + } + + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + bind(tab.ptyId, worktreeId) + bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId) + const layout = session?.terminalLayoutsByTabId[tab.id] + for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { + bind(ptyId, worktreeId) + } + } + } + return worktreeIdByPtyId +} + +function indexPersistedPtySurfaceBindings( + session: WorkspaceSessionState | null | undefined +): ReadonlyMap< + string, + { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } +> { + const bindingByPtyId = new Map< + string, + { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } + >() + const ambiguousPtyIds = new Set() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + for (const [leafId, ptyId] of Object.entries( + session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + )) { + if (!ptyId || ambiguousPtyIds.has(ptyId)) { + continue + } + const paneKey = makePaneKey(tab.id, leafId) + const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (!incarnationId) { + continue + } + const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId } + const existing = bindingByPtyId.get(ptyId) + if ( + existing && + (existing.worktreeId !== worktreeId || + existing.paneKey !== paneKey || + existing.incarnationId !== incarnationId) + ) { + bindingByPtyId.delete(ptyId) + ambiguousPtyIds.add(ptyId) + continue + } + bindingByPtyId.set(ptyId, binding) + } + } + } + return bindingByPtyId +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) { + return false + } + for (const value of a) { + if (!b.has(value)) { + return false + } + } + return true +} + +function parseRuntimeWorktreeId( + worktreeId: string +): { repoId: string; worktreePath: string } | null { + const parsed = splitWorktreeId(worktreeId) + if (!parsed?.repoId) { + return null + } + if (!parsed.worktreePath) { + return null + } + return parsed +} + +type RuntimeWorktreeSummaryPathCandidate = { + summary: RuntimeWorktreePsSummary + order: number +} + +type RuntimeWorktreeSummaryPathIndex = { + platformByRepoId: ReadonlyMap + posixAbsolute: Map + posixRelative: Map + windows: Map + windowsAbsolute: Map +} + +function buildRuntimeWorktreeSummaryPathIndex( + summaries: ReadonlyMap, + resolvedWorktrees: readonly ResolvedWorktree[], + platformByRepoId: ReadonlyMap +): RuntimeWorktreeSummaryPathIndex { + const index: RuntimeWorktreeSummaryPathIndex = { + platformByRepoId, + posixAbsolute: new Map(), + posixRelative: new Map(), + windows: new Map(), + windowsAbsolute: new Map() + } + for (const [order, worktree] of resolvedWorktrees.entries()) { + const summary = summaries.get(worktree.id) + if (!summary) { + continue + } + const platform = platformByRepoId.get(worktree.repoId) ?? process.platform + const candidate = { summary, order } + if (isPosixAbsoluteRuntimeWorktreePath(worktree.path)) { + setFirstRuntimeWorktreePathCandidate( + index.posixAbsolute, + runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), + candidate + ) + continue + } + + const windowsKey = runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, 'win32') + setFirstRuntimeWorktreePathCandidate(index.windows, windowsKey, candidate) + if (isWindowsAbsolutePathLike(worktree.path)) { + setFirstRuntimeWorktreePathCandidate(index.windowsAbsolute, windowsKey, candidate) + } else if (platform !== 'win32') { + setFirstRuntimeWorktreePathCandidate( + index.posixRelative, + runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), + candidate + ) + } + } + return index +} + +function findRuntimeWorktreeSummaryByPath( + index: RuntimeWorktreeSummaryPathIndex, + repoId: string, + worktreePath: string, + platform: NodeJS.Platform +): RuntimeWorktreePsSummary | null { + if (isPosixAbsoluteRuntimeWorktreePath(worktreePath)) { + return ( + index.posixAbsolute.get(runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform)) + ?.summary ?? null + ) + } + + const windowsKey = runtimeWorktreeSummaryPathKey(repoId, worktreePath, 'win32') + if (platform === 'win32' || isWindowsAbsolutePathLike(worktreePath)) { + return index.windows.get(windowsKey)?.summary ?? null + } + + const posixCandidate = index.posixRelative.get( + runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform) + ) + const windowsCandidate = index.windowsAbsolute.get(windowsKey) + // Why: a malformed path can match both the POSIX and Windows indexes; keep the old pairwise scan's first-match order. + if (!posixCandidate) { + return windowsCandidate?.summary ?? null + } + if (!windowsCandidate || posixCandidate.order < windowsCandidate.order) { + return posixCandidate.summary + } + return windowsCandidate.summary +} + +function setFirstRuntimeWorktreePathCandidate( + candidates: Map, + key: string, + candidate: RuntimeWorktreeSummaryPathCandidate +): void { + if (!candidates.has(key)) { + candidates.set(key, candidate) + } +} + +function isPosixAbsoluteRuntimeWorktreePath(worktreePath: string): boolean { + return worktreePath.startsWith('/') && !worktreePath.startsWith('//') +} + +function runtimeWorktreeSummaryPathKey( + repoId: string, + worktreePath: string, + platform: NodeJS.Platform +): string { + return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}` +} + +function includeTargetResolvedWorktree( + resolvedWorktrees: ResolvedWorktree[], + targetWorktree: ResolvedWorktree | null +): ResolvedWorktree[] { + if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) { + return resolvedWorktrees + } + return [...resolvedWorktrees, targetWorktree] +} + +function findResolvedWorktreeIdForPath( + resolvedWorktrees: ResolvedWorktree[], + cwd: string, + targetWorktreeId?: string | null +): string | null { + if (!cwd) { + return null + } + const matches = resolvedWorktrees + .filter((worktree) => isPathInsideOrEqual(worktree.path, cwd)) + .sort((left, right) => right.path.length - left.path.length) + // Why: a cwd cannot distinguish folder-workspace siblings, which all share one + // directory. Break that tie toward the caller's target instead of store order, + // so an unattributed PTY still lands in the workspace being listed. Only ties at + // the deepest path qualify — a nested worktree must still beat its parent. + const deepest = matches.filter((worktree) => worktree.path.length === matches[0]?.path.length) + return ( + (deepest.length > 1 + ? deepest.find((worktree) => worktree.id === targetWorktreeId)?.id + : undefined) ?? + matches[0]?.id ?? + null + ) +} + +function getLeafWorktreeStatus( + leaf: RuntimeLeafRecord, + tabTitle: string | null +): RuntimeWorktreeStatus { + // Why: recompute from the live title each call (no sticky state) so worktree.ps mirrors the desktop sidebar's getWorktreeStatus. + const titleCandidates = [ + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: tabTitle, updatedAt: 0 } + ] + const latestTitle = getLatestAgentCandidateTitle(...titleCandidates) + const detected = latestTitle ? detectAgentStatusFromTitle(latestTitle) : leaf.lastAgentStatus + return getDetectedWorktreeStatus(detected, leaf.ptyId !== null) +} + +function classifyLatestAgentTitle( + ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] +): 'agent' | 'management' | 'neutral' { + return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) +} + +function getLatestPtyTitle(pty: RuntimePtyWorktreeRecord): string | null { + return getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) +} + +function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): string | null { + return getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: tabTitle, updatedAt: 0 } + ) +} + +// Why: an 'agent' title only proves an agent owns the pane when something other than a +// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028). +function agentTitleProvesAgentPresence( + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + classification === 'agent' && + !isOpenCodeNativeTitle(title) && + !isQuarterCircleSpinnerOnlyAgentTitle(title) + ) +} + +function ptyTitleProvesAgentPresence( + pty: RuntimePtyWorktreeRecord, + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + agentTitleProvesAgentPresence(title, classification) || + (isQuarterCircleSpinnerOnlyAgentTitle(title) && + pty.launchAgent === 'claude' && + pty.launchToken !== null && + pty.launchIncarnationId === pty.incarnationId) + ) +} + +function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { + if (!title) { + return 'neutral' + } + if (isClaudeManagementTitle(title)) { + return 'management' + } + return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral' +} + +function isTerminalSendSettlementAgent( + agent: TuiAgent | null | undefined +): agent is 'claude' | 'codex' { + return agent === 'claude' || agent === 'codex' +} + +function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null { + // Why: one forward cursor keeps hostile unterminated OSC output linear-time. + let last: { start: number; end: number } | null = null + let searchFrom = 0 + while (searchFrom < data.length) { + const start = data.indexOf('\x1b]', searchFrom) + if (start === -1) { + break + } + const command = data[start + 2] + if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') { + searchFrom = start + 2 + continue + } + let cursor = start + 4 + for (; cursor < data.length; cursor += 1) { + if (data[cursor] === '\x07') { + last = { start, end: cursor + 1 } + searchFrom = cursor + 1 + break + } + if (data[cursor] !== '\x1b') { + continue + } + if (data[cursor + 1] === '\\') { + last = { start, end: cursor + 2 } + searchFrom = cursor + 2 + } else { + searchFrom = cursor + } + break + } + if (cursor === data.length) { + break + } + } + return last +} + +function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean { + if (!title) { + return false + } + return isClaudeManagementTitle(title) || isShellProcess(title) +} + +function getLatestAgentCandidateTitle( + ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] +): string | null { + return getLatestAgentCandidateTitleInfo(...titles)?.title ?? null +} + +function getLatestAgentCandidateTitleInfo( + ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] +): { title: string; updatedAt: number } | null { + let latest: { title: string; updatedAt: number } | null = null + for (const candidate of titles) { + const title = candidate.title?.trim() + if (!title) { + continue + } + const updatedAt = candidate.updatedAt ?? 0 + if (!latest || updatedAt > latest.updatedAt) { + latest = { title, updatedAt } + } + } + return latest +} + +function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { + return getDetectedWorktreeStatus(detectAgentStatusFromTitle(title), hasPty) +} + +function getDetectedWorktreeStatus( + detected: AgentStatus | null, + hasPty: boolean +): RuntimeWorktreeStatus { + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return hasPty ? 'active' : 'inactive' +} + +function mapExplicitAgentStateToRuntimeTerminalStatus( + state: AgentStatusEntry['state'] +): NonNullable { + switch (state) { + case 'blocked': + case 'waiting': + return 'permission' + case 'working': + return 'working' + case 'done': + return 'idle' + } +} + +function addRuntimeWorkingTerminalEvidence( + evidenceByWorktreeId: Map, + worktreeId: string, + evidence: RuntimeWorkingTerminalEvidence +): void { + const existing = evidenceByWorktreeId.get(worktreeId) + if (existing) { + existing.push(evidence) + } else { + evidenceByWorktreeId.set(worktreeId, [evidence]) + } +} + +function runtimeWorkingTerminalEvidenceMatchesSource( + evidence: RuntimeWorkingTerminalEvidence, + source: RuntimeWorktreeAgentSource +): boolean { + if (evidence.paneKey) { + return ( + evidence.paneKey === source.paneKey || + Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId) + ) + } + if (evidence.ptyId && source.ptyId) { + return evidence.ptyId === source.ptyId + } + return Boolean(evidence.tabId && evidence.tabId === source.tabId) +} + +function mergeWorktreeSummaryStatus( + summary: RuntimeWorktreePsSummary, + next: RuntimeWorktreeStatus, + nextWorkingMode?: RuntimeWorktreePsSummary['workingMode'] +): void { + const currentPriority = WORKTREE_STATUS_PRIORITY[summary.status] + const nextPriority = WORKTREE_STATUS_PRIORITY[next] + if (nextPriority > currentPriority) { + summary.status = next + if (next === 'working' && nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + return + } + if (nextPriority === currentPriority && next === 'working') { + if (nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + } +} + +function normalizeTerminalChunk( + chunk: string, + pendingAnsi: string = '' +): { text: string; pendingAnsi: string } { + // Why: skip full ANSI/OSC scanning for the common plain-text PTY chunk (perf on high-throughput streams). + if (pendingAnsi.length === 0 && !terminalChunkNeedsNormalization(chunk)) { + return { text: chunk, pendingAnsi: '' } + } + const combined = `${pendingAnsi}${chunk}` + const parts: string[] = [] + let textStart = 0 + for (let index = 0; index < combined.length; index += 1) { + const char = combined[index] + if (char === '\x1b') { + appendTerminalNormalizedSpan(parts, combined, textStart, index) + if (index + 1 >= combined.length) { + return { text: parts.join(''), pendingAnsi: combined.slice(index) } + } + const parsed = parseAnsiControlSequence(combined, index) + if (!parsed) { + return { + text: parts.join(''), + pendingAnsi: trimPendingAnsiControl(combined.slice(index)) + } + } + if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { + // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. + parts.push(combined.slice(index, parsed.endIndex + 1)) + } + index = parsed.endIndex + textStart = index + 1 + continue + } + if (char === '\r' && combined[index + 1] === '\n') { + appendTerminalNormalizedSpan(parts, combined, textStart, index) + parts.push('\n') + index += 1 + textStart = index + 1 + continue + } + const code = combined.charCodeAt(index) + if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { + appendTerminalNormalizedSpan(parts, combined, textStart, index) + parts.push(char) + textStart = index + 1 + } else if (!isTerminalPreviewPrintableCodeUnit(code)) { + appendTerminalNormalizedSpan(parts, combined, textStart, index) + textStart = index + 1 + } + } + appendTerminalNormalizedSpan(parts, combined, textStart, combined.length) + return { text: parts.join(''), pendingAnsi: '' } +} + +function appendTerminalNormalizedSpan( + parts: string[], + value: string, + start: number, + end: number +): void { + if (end > start) { + parts.push(value.slice(start, end)) + } +} + +function isTerminalPreviewPrintableCodeUnit(code: number): boolean { + return code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f) +} + +function terminalChunkNeedsNormalization(chunk: string): boolean { + for (let index = 0; index < chunk.length; index++) { + const code = chunk.charCodeAt(index) + if ( + code === 0x1b || + code === 0x7f || + code === 0x0d || + code < 0x09 || + (code > 0x0a && code < 0x20) || + (code >= 0x80 && code <= 0x9f) + ) { + return true + } + } + return false +} + +function trimPendingAnsiControl(value: string): string { + if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) { + return value + } + const introducer = value.slice(0, Math.min(2, value.length)) + const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length) + return `${introducer}${value.slice(-suffixBudget)}` +} + +function isTerminalPreviewLineControl(parsed: { + final: string + params: string + firstParam: number | null +}): boolean { + if (!hasCanonicalNumericCsiParams(parsed.params)) { + return false + } + if (parsed.final === 'K') { + const mode = parsed.firstParam ?? 0 + return mode === 0 || mode === 1 || mode === 2 + } + return ( + parsed.final === 'A' || + parsed.final === 'G' || + parsed.final === '`' || + parsed.final === 'D' || + parsed.final === 'C' + ) +} + +function maxTimestamp(left: number | null, right: number | null): number | null { + if (left === null) { + return right + } + if (right === null) { + return left + } + return Math.max(left, right) +} + +function compareWorktreePs( + left: RuntimeWorktreePsSummary, + right: RuntimeWorktreePsSummary +): number { + // Pinned and unread worktrees sort above others so they survive truncation. + if (left.isPinned !== right.isPinned) { + return left.isPinned ? -1 : 1 + } + if (left.unread !== right.unread) { + return left.unread ? -1 : 1 + } + // Why: worktree.ps is truncated for mobile, so host-visible activity must sort above inactive rows. + if (left.hasHostSidebarActivity !== right.hasHostSidebarActivity) { + return left.hasHostSidebarActivity ? -1 : 1 + } + const leftLast = left.lastOutputAt ?? -1 + const rightLast = right.lastOutputAt ?? -1 + if (leftLast !== rightLast) { + return rightLast - leftLast + } + if (left.liveTerminalCount !== right.liveTerminalCount) { + return right.liveTerminalCount - left.liveTerminalCount + } + return left.path.localeCompare(right.path) +} diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 2010a219b7c..d2c76ae1ee8 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -12,13 +12,15 @@ export const WORKTREE_CATALOG_METHODS: RpcMethod[] = [ name: 'worktree.ps', params: WorktreePsParams, handler: async (params, context) => { - const result = await context.runtime.getWorktreePs( - params.limit, - supportsWorktreeVisibilitySourceDefaults( - context, - params.supportsWorktreeVisibilitySourceDefaults - ) + const supportsSourceDefaults = supportsWorktreeVisibilitySourceDefaults( + context, + params.supportsWorktreeVisibilitySourceDefaults ) + const result = context.signal + ? await context.runtime.getWorktreePs(params.limit, supportsSourceDefaults, { + signal: context.signal + }) + : await context.runtime.getWorktreePs(params.limit, supportsSourceDefaults) // Why: callers that never send the field get the byte-exact legacy response. return params.afterSnapshotId === undefined ? result