diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a649abebcc9..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -19,7 +19,12 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' @@ -517,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -525,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a7516ec4073..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 372cf304061..ee8d6edd608 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10497,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index afd10bee579..38414da2155 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -661,7 +661,7 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -1037,7 +1037,7 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -5041,6 +5041,46 @@ export class OrcaRuntimeService { } } + private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + if (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) + } + + // 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): void { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { @@ -17789,6 +17829,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived