Files
orca/src/main/ipc/pty.ts
T

3753 lines
152 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines -- Why: PTY IPC is intentionally centralized in one
main-process module so spawn-time environment scoping, lifecycle cleanup,
foreground-process inspection, and renderer IPC stay behind a single audited
boundary. Splitting it by line count would scatter tightly coupled terminal
process behavior across files without a cleaner ownership seam. */
import { join, delimiter } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
type BrowserWindow,
type IpcMainEvent,
type IpcMainInvokeEvent,
type WebContents,
ipcMain,
app
} from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
import type { GlobalSettings, TuiAgent } from '../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import {
isWslShellName,
resolveLocalWindowsTerminalRuntimeOptions
} from '../../shared/local-windows-terminal-runtime'
import { openCodeHookService } from '../opencode/hook-service'
import { mimoCodeHookService } from '../mimo/hook-service'
import {
getCommandTokenPathBasename,
getFirstCommandToken
} from '../../shared/command-token-scanner'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
import { isPwshAvailable } from '../pwsh'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { createPtySpawnTiming } from './pty-spawn-timing'
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment'
import {
isClaudeAuthSwitchInProgress,
markClaudePtyExited,
markClaudePtySpawned
} from '../claude-accounts/live-pty-gate'
import {
applyTerminalAttributionEnv,
resolveAttributionShellFamily
} from '../attribution/terminal-attribution'
import { registerPty, unregisterPty } from '../memory/pty-registry'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { track } from '../telemetry/client'
import { classifyError } from '../telemetry/classify-error'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import {
agentKindSchema,
launchSourceSchema,
requestKindSchema
} from '../../shared/telemetry-events'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
} from '../../shared/terminal-input'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import {
isTerminalLeafId,
makePaneKey,
parseLegacyNumericPaneKey,
parsePaneKey
} from '../../shared/stable-pane-id'
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
} from '../agent-hooks/migration-unsupported-pty-state'
import { parseWslPath } from '../wsl'
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
answerStartupTerminalColorQueries,
clearStartupTerminalColorQueryReplies,
getStartupTerminalColorQueryReplyColors,
moveStartupTerminalColorQueryReplies,
registerStartupTerminalColorQueryReplies
} from './terminal-startup-color-query-replies'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus
} from '../project-groups/folder-workspace-path-status'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
// ─── Provider Registry ──────────────────────────────────────────────
// Routes PTY operations by connectionId. null = local provider.
// SSH providers will be registered here in Phase 1.
let localProvider: IPtyProvider = new LocalPtyProvider()
type FreshLocalFallbackProvider = IPtyProvider & {
routesFreshSpawnsToLocalProvider?: true
}
const sshProviders = new Map<string, IPtyProvider>()
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
// Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent
// write/resize/kill calls only carry the PTY ID. This map lets us route
// post-spawn operations to the correct provider without the renderer needing
// to track connectionId per-PTY.
const ptyOwnership = new Map<string, string | null>()
// Why: mobile clients must mirror desktop PTY geometry even when the renderer
// cannot provide an xterm snapshot yet, such as immediately after tab creation.
const ptySizes = new Map<string, { cols: number; rows: number }>()
// Why: PTY data batching is window-bound, but the "recent user input" signal
// is PTY-scoped and must be cleared by every teardown path, including SSH and
// daemon shutdowns that do not flow through the local provider exit listener.
const lastInputAtByPty = new Map<string, number>()
const interactiveOutputCharsByPty = new Map<string, number>()
const activeRendererPtys = new Set<string>()
const visibleRendererPtys = new Set<string>()
const rendererVisibilityKnownPtys = new Set<string>()
const pendingHiddenRendererResizeOutputPtys = new Set<string>()
const deliveredHiddenRendererResizeOutputPtys = new Set<string>()
const KEEP_HISTORY_STOP_SETTLE_MS = 1_000
const KEEP_HISTORY_STOP_POLL_MS = 100
// Why: the agent-hooks server caches per-paneKey state (last prompt, last
// tool) that otherwise grows unbounded as panes come and go. Track the
// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY
// teardown — the renderer knows the paneKey but the PTY lifecycle does not
// without this mapping.
const ptyPaneKey = new Map<string, string>()
// Why: reverse of ptyPaneKey — callers that receive a paneKey from outside the
// PTY lifecycle (e.g. the agent-hook server routing a cursor-agent status event
// back into the pane's data stream) need to find the ptyId for that paneKey.
// Kept in lock-step with ptyPaneKey via the same spawn and teardown sites.
const paneKeyPtyId = new Map<string, 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_ENDPOINT',
// Why: PR 2778 briefly exported this scoped Claude settings path. Keep
// deleting stale inherited values so older PTYs cannot leak the reverted path.
'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
] as const
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
return paneKeyPtyId.get(paneKey)
}
// Why: consumers (currently the cursor-agent synthesized-spinner loop in
// main/index.ts) need to tear down paneKey-scoped state when a PTY exits so
// intervals / timers cannot leak for the process lifetime. A callback
// registry keeps the cross-module dependency narrow — clearProviderPtyState
// only has to know about "things to notify", not about every consumer's
// internals.
type PaneKeyTeardownListener = (paneKey: string) => void
const paneKeyTeardownListeners = new Set<PaneKeyTeardownListener>()
export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void {
paneKeyTeardownListeners.add(listener)
return () => paneKeyTeardownListeners.delete(listener)
}
// Why: pre-signal handshake — the renderer declares it will own the serializer
// for a paneKey BEFORE issuing pty:spawn. The cooperation gate at provider.spawn
// return consults this map to suppress the daemon-snapshot seed when a renderer
// is taking over. Generation tokens prevent paneKey-reuse races during teardown:
// a paneKeyTeardownListener cleanup only fires settle when the captured gen
// still matches, so a remount that pre-signals before the old PTY's teardown
// runs is preserved. See docs/mobile-prefer-renderer-scrollback.md.
let pendingSerializerGenSeq = 0
const pendingByPaneKey = new Map<string, { gen: number; ownerWebContentsId: number | null }>()
const pendingPaneSerializerCleanupRegistered = new Set<number>()
type PaneSpawnReservation = {
promise: Promise<PaneSpawnReservationResult>
resolve: (result: PaneSpawnReservationResult) => void
reject: (error: unknown) => void
}
type PaneSpawnReservationResult = {
id: string
launchConfig?: SleepingAgentLaunchConfig
} & Partial<PtySpawnResult>
// Why: mobile runtime materialization and a newly-focused renderer pane can
// race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts
// the winner's PTY instead of creating a duplicate shell.
const paneSpawnReservationsByPaneKey = new Map<string, PaneSpawnReservation>()
// Why: at PTY spawn time we capture the gen that was pending for the spawn's
// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey
// remount that replaces the pending entry with a new gen would still get
// stomped by the old PTY's teardown firing settle on the wrong gen.
const ptyPendingGenByPtyId = new Map<string, number>()
// Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal.
// Populated on settlePaneSerializer (renderer has registered for this ptyId)
// and cleared on PTY teardown.
const rendererSerializerByPtyId = new Set<string>()
function parseValidPaneKey(paneKey: unknown): ReturnType<typeof parsePaneKey> {
if (typeof paneKey !== 'string' || paneKey.length > 256) {
return null
}
return parsePaneKey(paneKey)
}
function isValidPaneKey(paneKey: unknown): paneKey is string {
return parseValidPaneKey(paneKey) !== null
}
function shouldRefreshNativeClaudeAgentTeamsEnv(args: {
command?: string
launchConfig?: SleepingAgentLaunchConfig
}): boolean {
const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || ''
const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? ''
const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim()
return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch)
}
function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : ''
if (!isValidPaneKey(normalizedPaneKey)) {
return null
}
ptyPaneKey.set(ptyId, normalizedPaneKey)
paneKeyPtyId.set(normalizedPaneKey, ptyId)
return normalizedPaneKey
}
function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void {
pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId)
for (const [paneKey, pending] of pendingByPaneKey) {
if (pending.ownerWebContentsId === ownerWebContentsId) {
pendingByPaneKey.delete(paneKey)
}
}
}
function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void {
if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) {
return
}
pendingPaneSerializerCleanupRegistered.add(sender.id)
sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id))
}
function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number {
const gen = ++pendingSerializerGenSeq
registerPendingPaneSerializerCleanup(sender)
pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null })
return gen
}
function reservePaneSpawn(paneKey: string): PaneSpawnReservation {
let resolve!: (result: PaneSpawnReservationResult) => void
let reject!: (error: unknown) => void
const promise = new Promise<PaneSpawnReservationResult>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
promise.catch(() => {})
const reservation = { promise, resolve, reject }
paneSpawnReservationsByPaneKey.set(paneKey, reservation)
return reservation
}
function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void {
if (paneSpawnReservationsByPaneKey.get(paneKey) === reservation) {
paneSpawnReservationsByPaneKey.delete(paneKey)
}
}
function rejectPaneSpawnReservation(
paneKey: string | null | undefined,
reservation: PaneSpawnReservation | null | undefined,
error: unknown
): void {
if (!reservation) {
return
}
reservation.reject(error)
if (paneKey) {
clearPaneSpawnReservation(paneKey, reservation)
}
}
function resolvePaneSpawnReservation<T extends PaneSpawnReservationResult>(
paneKey: string | null | undefined,
reservation: PaneSpawnReservation | null | undefined,
response: T
): T {
if (!reservation) {
return response
}
reservation.resolve(response)
if (paneKey) {
clearPaneSpawnReservation(paneKey, reservation)
}
return response
}
function settlePendingPaneSerializer(paneKey: string, gen: number): void {
if (pendingByPaneKey.get(paneKey)?.gen === gen) {
pendingByPaneKey.delete(paneKey)
}
}
export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean {
return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey)
}
function getProvider(connectionId: string | null | undefined): IPtyProvider {
if (!connectionId) {
return localProvider
}
const provider = sshProviders.get(connectionId)
if (!provider) {
throw new Error(`No PTY provider for connection "${connectionId}"`)
}
return provider
}
function getProviderForPty(ptyId: string): IPtyProvider {
const connectionId = ptyOwnership.get(ptyId)
if (connectionId === undefined) {
return localProvider
}
return getProvider(connectionId)
}
function hasPtyProviderForInspection(ptyId: string): boolean {
// Why: process inspection is background polling; disconnected SSH hosts should
// read as idle instead of surfacing repeated IPC errors.
const connectionId = ptyOwnership.get(ptyId)
return connectionId == null || sshProviders.has(connectionId)
}
function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string {
return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId
}
function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string {
return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId
}
function stripRemotePaneEnvWhenHooksDisabled(
connectionId: string | null | undefined,
env: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!connectionId || isRemoteAgentHooksEnabled()) {
return env
}
if (
!env ||
(!('ORCA_PANE_KEY' in env) &&
!('ORCA_TAB_ID' in env) &&
!('ORCA_WORKTREE_ID' in env) &&
!('ORCA_AGENT_LAUNCH_TOKEN' in env))
) {
return env
}
const stripped = { ...env }
delete stripped.ORCA_PANE_KEY
delete stripped.ORCA_TAB_ID
delete stripped.ORCA_WORKTREE_ID
delete stripped.ORCA_AGENT_LAUNCH_TOKEN
return stripped
}
function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined {
try {
return getProviderForPty(ptyId)
} catch {
return undefined
}
}
function getProviderForStartupTerminalColorReply(ptyId: string): IPtyProvider | undefined {
const ownedConnectionId = ptyOwnership.get(ptyId)
if (ownedConnectionId !== undefined) {
return getProvider(ownedConnectionId)
}
const parsedSshId = parseAppSshPtyId(ptyId)
if (parsedSshId) {
return getProvider(parsedSshId.connectionId)
}
return localProvider
}
export function answerStartupTerminalColorQueriesForPty(ptyId: string, data: string): string {
return answerStartupTerminalColorQueries(ptyId, data, getProviderForStartupTerminalColorReply)
}
function normalizeNodePtySpawnError(err: unknown): Error {
const rawMessage = err instanceof Error ? err.message : String(err)
const hintedMessage = addNodePtyRecoveryHint(rawMessage)
if (hintedMessage === rawMessage && err instanceof Error) {
return err
}
if (err instanceof Error) {
// Why: preserve the original stack/name/custom fields while returning the
// same recovery guidance as the renderer-driven pty:spawn path.
err.message = hintedMessage
return err
}
return new Error(hintedMessage)
}
function isPtyAlreadyGoneError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return isSshPtyNotFoundError(err) || /Session not found/i.test(message)
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
if (typeof timer.unref === 'function') {
timer.unref()
}
})
}
async function isProviderPtyLive(provider: IPtyProvider, ptyId: string): Promise<boolean> {
return (await provider.listProcesses()).some((session) => session.id === ptyId)
}
async function verifyPtyStopped(
provider: IPtyProvider,
ptyId: string,
opts: { keepHistory?: boolean } | undefined
): Promise<boolean> {
if (await isProviderPtyLive(provider, ptyId)) {
return false
}
if (!opts?.keepHistory) {
return true
}
const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS
while (Date.now() < deadline) {
await delay(KEEP_HISTORY_STOP_POLL_MS)
if (await isProviderPtyLive(provider, ptyId)) {
return false
}
}
return true
}
function finishPtyShutdown(
id: string,
connectionId: string | null | undefined,
store: Store | undefined
): void {
clearProviderPtyState(id)
if (connectionId) {
store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated')
}
ptyOwnership.delete(id)
markClaudePtyExited(id)
}
// ─── Host PTY env assembly ──────────────────────────────────────────
// Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active
// fallback in pty:spawn need the same set of host-local env injections
// (OpenCode plugin dir, agent-hook server coordinates, Pi/OMP managed
// extensions, Codex account home, dev-mode CLI overrides, GitHub attribution
// shims). They used to be implemented twice, which silently drifted —
// daemon-backed PTYs never got the OpenCode plugin, Pi integration, Codex
// home, or dev CLI PATH prepend, so status dots, Pi state, Codex switching, and CLI→dev
// routing were all broken for daemon users (the common case).
//
// Centralizing the injections here makes future additions fail-safe: a new
// variable added to this function lands in BOTH spawn paths or NEITHER.
export type BuildPtyHostEnvOptions = {
isPackaged: boolean
userDataPath: string
selectedCodexHomePath: string | null
skipCodexHomeEnv?: boolean
githubAttributionEnabled: boolean
/** The launch command the renderer chose for this PTY (e.g. 'pi', 'omp',
* 'claude'). Used to resolve the per-agent managed extension target for
* Pi / OMP - both consume `PI_CODING_AGENT_DIR` but default to different
* `~/.<kind>/agent` paths. Undefined for bare-shell spawns; defaults
* resolve to Pi for back-compat. NEVER infer from disk presence; that's
* the bug this option fixes (cross-agent shadowing when both dirs exist). */
launchCommand?: string
shellPath?: string
isWsl?: boolean
agentStatusHooksEnabled: boolean
networkProxySettings?: NetworkProxySettings
}
function readInheritedPath(baseEnv: Record<string, string>): string {
return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? ''
}
function firstPathEntry(pathValue: string | undefined): string | null {
const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0)
return first ?? null
}
function promoteAgentTeamsShimPath(
env: Record<string, string> | undefined,
requestedPath: string | undefined
): void {
if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) {
return
}
const shimPath = firstPathEntry(requestedPath)
if (!shimPath) {
return
}
const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path'
const currentPath = env[currentPathKey] ?? ''
const remaining = currentPath
.split(delimiter)
.filter((entry) => entry.length > 0 && entry !== shimPath)
// Why: host env injection can prepend Orca's attribution/dev shims. Claude
// Agent Teams must still resolve our fake tmux before any real tmux.
env[currentPathKey] = [shimPath, ...remaining].join(delimiter)
}
function deleteRequestedEnvKeys(
env: Record<string, string> | undefined,
keys: string[] | undefined
): void {
if (!env || !keys) {
return
}
for (const key of keys) {
delete env[key]
}
}
function shouldSkipCodexHomeEnvForWindowsShell(
shellPath: string | undefined,
cwd: string | undefined
): boolean {
return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null)
}
const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const
type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null
type PrepareClaudeAuth = (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>
function getCodexSelectionTargetForPty(
shellPath: string | undefined,
cwd: string | undefined,
wslDistro?: string | null
): CodexAccountSelectionTarget {
const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null
if (isWslShellName(shellPath) || wslPath) {
return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null }
}
return { runtime: 'host' }
}
function getCompatibleSelectedCodexHomePath(
target: CodexAccountSelectionTarget,
selectedCodexHomePath: string | null
): string | null {
if (!selectedCodexHomePath) {
return null
}
const wslInfo = parseWslPath(selectedCodexHomePath)
if (target.runtime === 'wsl') {
return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null
}
return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath))
? null
: selectedCodexHomePath
}
function readEnvWithProcessFallback(
baseEnv: Record<string, string>,
key: string
): string | undefined {
return baseEnv[key] ?? process.env[key]
}
function resolvePiAgentSourceDir(
baseEnv: Record<string, string>,
kind: PiAgentKind
): string | undefined {
const sourceKey = kind === 'omp' ? 'ORCA_OMP_SOURCE_AGENT_DIR' : 'ORCA_PI_SOURCE_AGENT_DIR'
const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR'
const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR'
const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey)
if (sourceDir) {
return sourceDir
}
const publicDir = readEnvWithProcessFallback(baseEnv, 'PI_CODING_AGENT_DIR')
const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey)
const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey)
// Why: if PI_CODING_AGENT_DIR is just a restored Orca overlay from either
// kind and the matching source shadow is absent, remirroring it would leak
// another agent's overlay tree into this launch. Fall through to defaults.
if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) {
return publicDir
}
return readShellStartupEnvVar(
'PI_CODING_AGENT_DIR',
baseEnv.HOME ?? process.env.HOME,
baseEnv.SHELL ?? process.env.SHELL
)
}
function resolveScopedPiAgentSourceDir(
baseEnv: Record<string, string>,
kind: PiAgentKind
): string | undefined {
const sourceKey = kind === 'omp' ? 'ORCA_OMP_SOURCE_AGENT_DIR' : 'ORCA_PI_SOURCE_AGENT_DIR'
return readEnvWithProcessFallback(baseEnv, sourceKey)
}
function clearPiAgentShadowEnv(baseEnv: Record<string, string>, kind: PiAgentKind): void {
if (kind === 'omp') {
delete baseEnv.ORCA_OMP_CODING_AGENT_DIR
delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
return
}
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR
}
function exposePiManagedExtensionEnv(
baseEnv: Record<string, string>,
kind: PiAgentKind,
managedEnv: Record<string, string>
): void {
if (kind === 'omp') {
delete baseEnv.ORCA_OMP_CODING_AGENT_DIR
if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) {
baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR
} else {
delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR
}
if (managedEnv.ORCA_OMP_STATUS_EXTENSION) {
baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION
} else {
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
}
return
}
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) {
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR
} else {
delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR
}
}
function mergePtyEnvDeletions(
existingKeys: string[] | undefined,
additionalKeys: readonly string[]
): string[] | undefined {
if (!existingKeys && additionalKeys.length === 0) {
return undefined
}
return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys]))
}
function getInheritedAgentHookEnvKeysToDelete(
spawnEnv: Record<string, string> | undefined
): string[] {
const env = spawnEnv ?? {}
// Why: daemon/local providers merge process.env after main-process cleanup.
// Delete reverted or unavailable hook env keys there without dropping fresh
// receiver coordinates that buildPtyHostEnv intentionally set.
return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined)
}
// Why: when agent status is disabled, a nested Orca terminal can still pass
// through prior OpenCode or legacy Pi/OMP overlay env. Restore the user's
// original source dir when Orca recorded one, otherwise strip only values
// known to be ours.
function restoreOrStripOverlayEnv(
baseEnv: Record<string, string>,
keys: {
primary: string
overlay: string
source: string
}
): void {
const sourceValue = baseEnv[keys.source] ?? process.env[keys.source]
const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay]
if (sourceValue) {
baseEnv[keys.primary] = sourceValue
} else if (overlayValue && baseEnv[keys.primary] === overlayValue) {
delete baseEnv[keys.primary]
}
delete baseEnv[keys.overlay]
delete baseEnv[keys.source]
}
function isMimoLaunchCommand(launchCommand: string | undefined): boolean {
const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? ''))
.toLowerCase()
.replace(/\.(?:cmd|exe|sh)$/, '')
return binary === 'mimo'
}
function resolveMimocodeSourceHome(baseEnv: Record<string, string>): string | undefined {
const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME
if (sourceHome) {
return sourceHome
}
const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME
const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME
if (configHome && orcaHome && configHome === orcaHome) {
return undefined
}
return configHome
}
function resolveOpenCodeSourceConfigDir(baseEnv: Record<string, string>): string | undefined {
const sourceDir =
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
if (sourceDir) {
return sourceDir
}
const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR
const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR
// Why: nested Orca terminals inherit OPENCODE_CONFIG_DIR from the parent
// PTY. If there is no recorded source dir, that value is Orca-owned, not a
// user config. Treating it as user config makes child Orcas mirror Orca's
// hook dir and can create large OpenCode runtime trees per terminal.
if (configDir && orcaConfigDir && configDir === orcaConfigDir) {
return undefined
}
return (
configDir ??
readShellStartupEnvVar(
'OPENCODE_CONFIG_DIR',
baseEnv.HOME ?? process.env.HOME,
baseEnv.SHELL ?? process.env.SHELL
)
)
}
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
*
* This is the single source of truth for the env shape an Orca PTY needs
* BEFORE the provider-specific wrapper (LocalPtyProvider's TERM/LANG defaults,
* DaemonPtyAdapter's subprocess env). Callers are responsible for the SSH
* guard — if `args.connectionId` is set, do NOT call this function, because
* every injection here is either host-loopback (hook server, attribution
* shims) or references paths on the local filesystem that would be meaningless
* to a remote shell.
*/
export function buildPtyHostEnv(
id: string,
baseEnv: Record<string, string>,
opts: BuildPtyHostEnvOptions
): Record<string, string> {
mergePersistedWindowsPath(baseEnv)
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
// Why: the Local path passes a baseEnv that already includes process.env
// (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The
// daemon path passes only args.env since process.env propagates to the
// daemon subprocess via fork inheritance, not the IPC wire. Checking both
// sources when reading a potentially-user-provided value keeps the guards
// in lock-step across spawn paths without pushing process.env onto the
// IPC wire unnecessarily.
const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
const hasLaunchCommand =
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
// Why: source shadows are agent-scoped. Trusting the other kind's source
// would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes.
const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi')
const preexistingOmpAgentDir =
piAgentKind === 'omp'
? resolvePiAgentSourceDir(baseEnv, 'omp')
: resolveScopedPiAgentSourceDir(baseEnv, 'omp')
if (opts.agentStatusHooksEnabled) {
// Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user
// value cannot coexist with an Orca-only injection. Hand the user's value
// (when present) to the hook service and let it materialize a source-scoped
// mirror overlay that lets the user's plugins and Orca's status plugin
// load together. See docs/opencode-config-dir-collision.md.
Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
// wrappers restore this PTY-scoped value after user startup files run.
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
if (preexistingOpenCodeConfigDir) {
// Why: terminals launched from another Orca terminal inherit the overlay
// as OPENCODE_CONFIG_DIR; keep the original source so overlays do not
// mirror overlays and drop the user's real config.
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
} else {
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome))
if (baseEnv.MIMOCODE_HOME) {
baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME
if (preexistingMimocodeHome) {
baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome
} else {
delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME
}
}
}
} else {
restoreOrStripOverlayEnv(baseEnv, {
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
})
restoreOrStripOverlayEnv(baseEnv, {
primary: 'MIMOCODE_HOME',
overlay: 'ORCA_MIMOCODE_HOME',
source: 'ORCA_MIMOCODE_SOURCE_HOME'
})
}
// Why: Claude/Codex native hooks run inside the shell process, so Orca
// must inject the loopback receiver coordinates before the agent starts.
// Without these env vars the global hook config cannot map callbacks back
// to the correct Orca pane.
// Why: nested Orca terminals can inherit another process's hook endpoint or
// token. Strip all hook runtime coordinates before injecting this PTY's fresh
// server values so callbacks route to the owning app/runtime.
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
delete baseEnv[key]
}
if (opts.agentStatusHooksEnabled) {
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
}
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root. Keep
// that home as the user's normal source of truth and install only Orca-owned,
// env-guarded extension files into the selected agent's extension dir.
if (opts.agentStatusHooksEnabled) {
clearPiAgentShadowEnv(baseEnv, 'pi')
clearPiAgentShadowEnv(baseEnv, 'omp')
if (piAgentKind === 'pi') {
const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi')
Object.assign(baseEnv, piEnv)
exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv)
}
if (shouldPrepareOmpShadow) {
const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp')
Object.assign(baseEnv, ompEnv)
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
}
} else {
// Why: when agent status is disabled we must strip BOTH kinds' shadow vars
// so a nested PTY does not inherit a stale overlay from either agent.
restoreOrStripOverlayEnv(baseEnv, {
primary: 'PI_CODING_AGENT_DIR',
overlay: 'ORCA_PI_CODING_AGENT_DIR',
source: 'ORCA_PI_SOURCE_AGENT_DIR'
})
restoreOrStripOverlayEnv(baseEnv, {
primary: 'PI_CODING_AGENT_DIR',
overlay: 'ORCA_OMP_CODING_AGENT_DIR',
source: 'ORCA_OMP_SOURCE_AGENT_DIR'
})
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
}
// Why: Codex account switching now materializes auth into an Orca-scoped
// runtime home, and Codex launched inside Orca terminals must use that same
// prepared home as quota fetches and other entry points. Keep the override
// PTY-scoped so dev/prod Orcas do not share hooks through ~/.codex.
if (opts.skipCodexHomeEnv) {
delete baseEnv.CODEX_HOME
delete baseEnv.ORCA_CODEX_HOME
} else if (opts.selectedCodexHomePath) {
baseEnv.CODEX_HOME = opts.selectedCodexHomePath
// Why: user startup files may re-export CODEX_HOME; shell-ready wrappers
// restore this runtime home before Codex can be launched from the prompt.
baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath
}
// Why: in dev mode the `orca` CLI defaults to the production userData
// path, which routes status updates to the packaged Orca instead of this
// dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from
// agents running inside dev terminals reach the correct runtime. We also
// prepend the dev CLI launcher directory to PATH so `orca` resolves to
// the dev build (which supports ORCA_USER_DATA_PATH) instead of the
// production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
const inheritedPath = readInheritedPath(baseEnv)
// Why: avoid a trailing delimiter when PATH is empty — some shells
// treat an empty segment as `.`, which would let commands resolve from
// the current working directory (a foot-gun we don't want to create
// for dev terminals).
baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin
}
// Why: GitHub attribution should only affect commands launched from
// Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps
// the behavior local to Orca instead of rewriting user git config or
// touching external shells.
if (!opts.githubAttributionEnabled) {
delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION
delete baseEnv.ORCA_GIT_COMMIT_TRAILER
delete baseEnv.ORCA_GH_PR_FOOTER
delete baseEnv.ORCA_GH_ISSUE_FOOTER
delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR
}
applyTerminalAttributionEnv(baseEnv, {
enabled: opts.githubAttributionEnabled,
userDataPath: opts.userDataPath,
shellFamily: resolveAttributionShellFamily({
shellPath: opts.shellPath,
isWsl: opts.isWsl
})
})
return baseEnv
}
function isClaudeLaunchCommand(command: string | undefined): boolean {
if (!command) {
return false
}
return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test(
command
)
}
function routesFreshSpawnsToLocalProvider(
provider: IPtyProvider
): provider is FreshLocalFallbackProvider {
return (provider as FreshLocalFallbackProvider).routesFreshSpawnsToLocalProvider === true
}
/** Register an SSH PTY provider for a connection. */
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
sshProviders.set(connectionId, provider)
}
/** Remove an SSH PTY provider when a connection is closed. */
export function unregisterSshPtyProvider(connectionId: string): void {
sshProviders.delete(connectionId)
}
/** Get the SSH PTY provider for a connection (for dispose on cleanup). */
export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined {
return sshProviders.get(connectionId)
}
/** Get the installed PTY provider (for direct access in tests/runtime).
*
* Returns the installed PTY provider — after `setLocalPtyProvider()` runs
* during daemon init this may be the routed adapter (specifically either
* `DaemonPtyAdapter` or its `DaemonPtyRouter` wrapper). Callers needing
* `LocalPtyProvider`-specific methods (`killOrphanedPtys`,
* `advanceGeneration`, `getPtyProcess`) must type-narrow or import the
* concrete class directly. */
export function getLocalPtyProvider(): IPtyProvider {
return localProvider
}
/** Replace the local PTY provider with a daemon-backed one.
* Call before registerPtyHandlers so the IPC layer routes through the daemon. */
export function setLocalPtyProvider(provider: IPtyProvider): void {
localProvider = provider
}
/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */
export function getPtyIdsForConnection(connectionId: string): string[] {
const ids: string[] = []
for (const [ptyId, connId] of ptyOwnership) {
if (connId === connectionId) {
ids.push(ptyId)
}
}
return ids
}
/**
* Remove all PTY ownership entries for a given connectionId.
* Why: when an SSH connection is closed, the remote PTYs are gone but their
* ownership entries linger. Without cleanup, subsequent spawn calls could
* look up a stale provider for those PTY IDs, and the map grows unboundedly.
*/
export function clearPtyOwnershipForConnection(connectionId: string): void {
for (const [ptyId, connId] of ptyOwnership) {
if (connId === connectionId) {
// Why: remote PTYs are gone after the SSH connection closes — their
// paneKey-scoped caches (agent-hooks server, OpenCode, Pi) must be swept
// the same way a local onExit would, otherwise they leak indefinitely
// for the process lifetime.
clearProviderPtyState(ptyId)
ptyOwnership.delete(ptyId)
}
}
}
// ─── Provider-scoped PTY state cleanup ──────────────────────────────
export function clearProviderPtyState(id: string): void {
// Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the
// node-pty process table. Centralizing provider cleanup avoids drift where a
// new teardown path forgets to remove one provider's overlay/hook state.
openCodeHookService.clearPty(id)
piTitlebarExtensionService.clearPty(id)
// Why: SSH exit and connection-teardown paths bypass pty.ts's local onExit
// callback but still need to release Claude account-switch guards.
markClaudePtyExited(id)
ptySizes.delete(id)
lastInputAtByPty.delete(id)
interactiveOutputCharsByPty.delete(id)
activeRendererPtys.delete(id)
visibleRendererPtys.delete(id)
rendererVisibilityKnownPtys.delete(id)
pendingHiddenRendererResizeOutputPtys.delete(id)
deliveredHiddenRendererResizeOutputPtys.delete(id)
clearStartupTerminalColorQueryReplies(id)
const paneKey = ptyPaneKey.get(id)
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
// Why: drop the memory-collector registration so a dead PTY does not keep
// trying to resolve its (now-dead) pid on every snapshot. Safe no-op for
// PTYs that were never registered (SSH-owned).
unregisterPty(id)
// Why: cover lifecycle paths that bypass runtime.onPtyExit — SSH reattach
// failures, SSH connection shutdown (clearPtyOwnershipForConnection), and
// daemon spawn-failure cleanup all funnel through here. Without this the
// watcher's per-PTY buffer and worktree binding outlive the PTY.
advertisedUrlWatcher.unbindPty(id)
clearMigrationUnsupportedPty(id)
agentHookServer.clearPaneKeyAliasesForPty(id, {
shouldClearStablePaneKey: (stablePaneKey) => {
// Why: when this PTY never rebuilt ptyPaneKey after restart, alias
// ownership is our only proof. Once a newer PTY owns the same stable
// paneKey, alias teardown must not erase that newer status.
const stablePaneOwner = paneKeyPtyId.get(stablePaneKey)
if (stablePaneOwner && stablePaneOwner !== id) {
return false
}
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
}
})
rendererSerializerByPtyId.delete(id)
// Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would
// otherwise accumulate entries for dead panes over the process lifetime.
// Use the spawn-time paneKey mapping since the server has no other way to
// correlate a ptyId back to its paneKey.
if (paneKey) {
if (stillOwnsPaneKey) {
agentHookServer.clearPaneState(paneKey)
paneKeyPtyId.delete(paneKey)
}
ptyPaneKey.delete(id)
// Why: drop the pre-signal pending entry only if it still belongs to THIS
// PTY's spawn generation. If a remount for the same paneKey has already
// pre-signaled a new gen, this teardown must NOT touch it — otherwise
// the second mount's hydration loses to the daemon-snapshot seed. See
// the generation-token rationale in
// docs/mobile-prefer-renderer-scrollback.md.
const ownedGen = ptyPendingGenByPtyId.get(id)
if (ownedGen !== undefined) {
settlePendingPaneSerializer(paneKey, ownedGen)
}
ptyPendingGenByPtyId.delete(id)
if (stillOwnsPaneKey) {
// Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId
// entries so a listener that re-reads the map sees the post-teardown
// state. Wrap each call so one throwing listener cannot block the rest.
for (const listener of paneKeyTeardownListeners) {
try {
listener(paneKey)
} catch (err) {
console.error('[pty] paneKey teardown listener threw', err)
}
}
}
}
}
export function deletePtyOwnership(id: string): void {
ptyOwnership.delete(id)
}
export function setPtyOwnership(id: string, connectionId: string | null): void {
ptyOwnership.set(id, connectionId)
}
// Why: localProvider.onData/onExit return unsubscribe functions. Without
// storing and calling these on re-registration, macOS app re-activation
// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking
// duplicate listeners that forward every event twice.
let localDataUnsub: (() => void) | null = null
let localExitUnsub: (() => void) | null = null
let didFinishLoadHandler: (() => void) | null = null
let didFinishLoadWebContents: WebContents | null = null
let rendererLifecycleResetWebContents: WebContents | null = null
let rendererLifecycleResetHandler: (() => void) | null = null
// Why: the "Restart daemon" path needs to re-bind provider→renderer listeners
// against the freshly-created adapter after replaceDaemonProvider swaps the
// module-level `localProvider` pointer. Without this, old subscribers stay
// bound to the disposed adapter and new PTY data silently drops. Saved at
// module scope so the restart flow (src/main/daemon/daemon-init.ts) can
// trigger a rebind without re-running the full registerPtyHandlers setup.
let rebindProviderListeners: (() => void) | null = null
export function rebindLocalProviderListeners(): void {
rebindProviderListeners?.()
}
export type PtyRendererDeliveryDebugSnapshot = {
pendingPtyCount: number
pendingChars: number
maxPendingCharsByPty: number
rendererInFlightPtyCount: number
rendererInFlightChars: number
maxRendererInFlightCharsByPty: number
activeRendererPtyCount: number
flushScheduled: boolean
peakPendingChars: number
peakMaxPendingCharsByPty: number
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
}
const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = {
pendingPtyCount: 0,
pendingChars: 0,
maxPendingCharsByPty: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
maxRendererInFlightCharsByPty: 0,
activeRendererPtyCount: 0,
flushScheduled: false,
peakPendingChars: 0,
peakMaxPendingCharsByPty: 0,
peakRendererInFlightChars: 0,
peakMaxRendererInFlightCharsByPty: 0,
ackGatedFlushSkipCount: 0
}
let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
})
let resetPtyRendererDeliveryDebugSnapshot = (): void => {}
export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
return readPtyRendererDeliveryDebugSnapshot()
}
export function resetPtyRendererDeliveryDebug(): void {
resetPtyRendererDeliveryDebugSnapshot()
}
function clearDidFinishLoadHandler(): void {
if (didFinishLoadHandler && didFinishLoadWebContents) {
didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler)
}
didFinishLoadHandler = null
didFinishLoadWebContents = null
}
function markRendererPtysHiddenForRendererLifecycleReset(): void {
// Why: renderer-owned hints die with the page; keep known-visibility state so
// surviving daemon/SSH PTYs fail closed until the new renderer reports again.
activeRendererPtys.clear()
visibleRendererPtys.clear()
}
function clearRendererLifecycleResetHandlers(): void {
if (!rendererLifecycleResetWebContents) {
return
}
if (rendererLifecycleResetHandler) {
rendererLifecycleResetWebContents.removeListener(
'did-start-loading',
rendererLifecycleResetHandler
)
rendererLifecycleResetWebContents.removeListener(
'render-process-gone',
rendererLifecycleResetHandler
)
rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler)
}
rendererLifecycleResetWebContents = null
rendererLifecycleResetHandler = null
}
function registerRendererLifecycleResetHandlers(webContents: WebContents): void {
clearRendererLifecycleResetHandlers()
markRendererPtysHiddenForRendererLifecycleReset()
rendererLifecycleResetWebContents = webContents
rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset
webContents.on('did-start-loading', rendererLifecycleResetHandler)
webContents.on('render-process-gone', rendererLifecycleResetHandler)
webContents.on('destroyed', rendererLifecycleResetHandler)
}
// Why: the "Restart daemon" flow needs to detach listeners from the current
// adapter *after* synthetic pty:exit events fan out (so the renderer receives
// them) but *before* replaceDaemonProvider swaps in the new adapter (so the
// new provider isn't missing bindings). This export narrows that window to
// the caller.
export function unbindLocalProviderListeners(): void {
localDataUnsub?.()
localExitUnsub?.()
localDataUnsub = null
localExitUnsub = null
}
// ─── IPC Registration ───────────────────────────────────────────────
export function registerPtyHandlers(
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService,
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store,
options?: {
awaitLocalPtyStartup?: () => Promise<void>
// Why: returns true (once, consuming the flag) for the crash-recovery reload
// so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787).
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
}
): void {
registerRendererLifecycleResetHandlers(mainWindow.webContents)
const getLocalPtyStartupPromise = (connectionId?: string | null): Promise<void> | undefined => {
if (connectionId) {
return undefined
}
// Why: during desktop cold start the daemon provider swap now overlaps
// first paint. Local spawns must wait before resolving getProvider(), while
// SSH/headless paths do not use the desktop daemon.
return options?.awaitLocalPtyStartup?.()
}
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('pty:spawn')
ipcMain.removeHandler('pty:kill')
ipcMain.removeHandler('pty:listSessions')
ipcMain.removeHandler('pty:hasPty')
ipcMain.removeHandler('pty:hasChildProcesses')
ipcMain.removeHandler('pty:getForegroundProcess')
ipcMain.removeHandler('pty:getCwd')
ipcMain.removeHandler('pty:getSize')
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:getMainBufferSnapshot')
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
ipcMain.removeHandler('pty:resetRendererDeliveryDebug')
ipcMain.removeHandler('pty:writeAccepted')
ipcMain.removeAllListeners('pty:write')
ipcMain.removeAllListeners('pty:ackColdRestore')
ipcMain.removeAllListeners('pty:ackData')
ipcMain.removeAllListeners('pty:serializeBuffer:response')
// Configure the local provider with app-specific hooks.
// Why: only LocalPtyProvider has the configure() method — daemon-backed
// providers handle subprocess spawning internally and don't need main-process
// hook injection. The hooks (buildSpawnEnv, onSpawned, etc.) only make sense
// when the PTY lives in the Electron main process.
if (localProvider instanceof LocalPtyProvider) {
localProvider.configure({
isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true,
getWindowsShell: () => getSettings?.()?.terminalWindowsShell,
getWindowsPowerShellImplementation: () =>
getSettings
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined,
pwshAvailable: () => isPwshAvailable(),
buildSpawnEnv: (id, baseEnv, ctx) => {
const codexSelectionTarget: CodexAccountSelectionTarget =
ctx?.isWsl === true
? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null }
: { runtime: 'host' }
const selectedCodexHomePath = getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
)
const env = buildPtyHostEnv(id, baseEnv, {
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: ctx?.command,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
// Why: agents need their own terminal handle at process start so they
// can self-identify in orchestration messages without an extra RPC.
const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE
const preAllocatedHandle =
requestedHandle && trustedTerminalHandleEnv.has(requestedHandle)
? requestedHandle
: runtime?.preAllocateHandleForPty(id)
if (requestedHandle && requestedHandle !== preAllocatedHandle) {
delete env.ORCA_TERMINAL_HANDLE
}
if (preAllocatedHandle) {
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
}
if (ctx?.isWsl === true) {
addOrcaWslInteropEnv(env)
}
return env
},
onSpawned: (id) => runtime?.onPtySpawned(id),
onExit: (id, code) => {
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code)
},
onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp)
})
}
// Why: batching PTY data into short flush windows (8ms ≈ half a frame)
// reduces IPC round-trips from hundreds/sec to ~120/sec under high
// throughput. Keystroke echo/redraws bypass this below because agent TUIs
// already spend tens of ms producing their redraw.
type PendingPtyData = {
data: string
startSeq?: number
containsBackgroundOutput?: boolean
}
type PtyDataPayload = {
id: string
data: string
seq?: number
rawLength?: number
background?: boolean
}
const pendingData = new Map<string, PendingPtyData>()
const rendererInFlightCharsByPty = new Map<string, number>()
const trustedTerminalHandleEnv = new Set<string>()
let flushTimer: ReturnType<typeof setTimeout> | null = null
let rendererInFlightTotalChars = 0
const PTY_BATCH_INTERVAL_MS = 8
const PTY_BATCH_DRAIN_CONTINUE_MS = 1
const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024
const PTY_BATCH_FLUSH_MAX_WRITES = 2
const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024
const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024
const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024
// Why: active panes need a bounded lane through old hidden bulk output so a
// keystroke redraw can reach the renderer before every background ACK lands.
const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024
// Why: keep the immediate path bounded to keystroke-sized TUI redraws;
// large output and non-interactive output must still use the batcher.
const INTERACTIVE_OUTPUT_WINDOW_MS = 100
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
let peakPendingChars = 0
let peakMaxPendingCharsByPty = 0
let peakRendererInFlightChars = 0
let peakMaxRendererInFlightCharsByPty = 0
let ackGatedFlushSkipCount = 0
function getMaxMapValue(values: Iterable<number>): number {
let max = 0
for (const value of values) {
max = Math.max(max, value)
}
return max
}
function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
let pendingChars = 0
let maxPendingCharsByPty = 0
for (const pending of pendingData.values()) {
const chars = pending.data.length
pendingChars += chars
maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars)
}
return {
pendingPtyCount: pendingData.size,
pendingChars,
maxPendingCharsByPty,
rendererInFlightPtyCount: rendererInFlightCharsByPty.size,
rendererInFlightChars: rendererInFlightTotalChars,
maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()),
activeRendererPtyCount: activeRendererPtys.size,
flushScheduled: flushTimer !== null,
peakPendingChars,
peakMaxPendingCharsByPty,
peakRendererInFlightChars,
peakMaxRendererInFlightCharsByPty,
ackGatedFlushSkipCount
}
}
function recordPtyRendererDeliveryPressure(): void {
const current = readCurrentPtyRendererDeliveryDebugSnapshot()
peakPendingChars = Math.max(peakPendingChars, current.pendingChars)
peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, current.maxPendingCharsByPty)
peakRendererInFlightChars = Math.max(peakRendererInFlightChars, current.rendererInFlightChars)
peakMaxRendererInFlightCharsByPty = Math.max(
peakMaxRendererInFlightCharsByPty,
current.maxRendererInFlightCharsByPty
)
}
readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot
resetPtyRendererDeliveryDebugSnapshot = () => {
peakPendingChars = 0
peakMaxPendingCharsByPty = 0
peakRendererInFlightChars = 0
peakMaxRendererInFlightCharsByPty = 0
ackGatedFlushSkipCount = 0
recordPtyRendererDeliveryPressure()
}
function isLikelyInteractiveRedraw(data: string): boolean {
if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) {
return true
}
// Why: Codex-style TUIs can repaint more than 1 KB per keypress. ANSI
// control redraws are still latency-sensitive, while plain command output
// should stay on the throughput batch path.
return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[')
}
function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean {
const lastInputAt = lastInputAtByPty.get(id)
if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) {
interactiveOutputCharsByPty.delete(id)
return false
}
if (!isLikelyInteractiveRedraw(data)) {
interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS)
return false
}
const usedChars = interactiveOutputCharsByPty.get(id) ?? 0
if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) {
interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS)
return false
}
interactiveOutputCharsByPty.set(id, usedChars + data.length)
return true
}
function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined {
return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined
}
function makePtyDataPayload(
id: string,
data: string,
startSeq: number | undefined,
containsBackgroundOutput: boolean | undefined
): PtyDataPayload {
const payload: PtyDataPayload = { id, data }
if (typeof startSeq === 'number') {
payload.seq = startSeq + data.length
payload.rawLength = data.length
}
if (containsBackgroundOutput === true) {
payload.background = true
}
return payload
}
function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number {
return Math.max(0, payload.rawLength ?? payload.data.length)
}
function canSendPtyDataToRenderer(id: string, options: { interactive?: boolean } = {}): boolean {
const totalLimit =
PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS +
(options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0)
// Why: the reserve is per active PTY, not global; one active pane should
// stay responsive without letting every background pane burst past the cap.
const ptyLimit =
PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS +
(options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0)
return (
(rendererInFlightCharsByPty.get(id) ?? 0) < ptyLimit &&
rendererInFlightTotalChars < totalLimit
)
}
function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void {
const charCount = getPtyPayloadCharCount(payload)
rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount)
rendererInFlightTotalChars += charCount
recordPtyRendererDeliveryPressure()
mainWindow.webContents.send('pty:data', payload)
}
function rendererPtyIsKnownHidden(id: string): boolean {
return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id)
}
function ptyHasHiddenRendererResizeOutput(id: string): boolean {
return (
pendingHiddenRendererResizeOutputPtys.has(id) ||
deliveredHiddenRendererResizeOutputPtys.has(id)
)
}
function markHiddenRendererResizeOutputDelivered(id: string): void {
if (!pendingHiddenRendererResizeOutputPtys.delete(id)) {
return
}
deliveredHiddenRendererResizeOutputPtys.add(id)
}
function clearDeliveredHiddenRendererResizeOutput(id: string): void {
deliveredHiddenRendererResizeOutputPtys.delete(id)
}
function clearHiddenRendererResizeOutput(id: string): void {
pendingHiddenRendererResizeOutputPtys.delete(id)
deliveredHiddenRendererResizeOutputPtys.delete(id)
}
function getPendingPtyFlushEntries(): [string, PendingPtyData][] {
const entries = Array.from(pendingData.entries())
const active: [string, PendingPtyData][] = []
const background: [string, PendingPtyData][] = []
for (const entry of entries) {
if (activeRendererPtys.has(entry[0])) {
active.push(entry)
} else {
background.push(entry)
}
}
return [...active, ...background]
}
function appendPendingPtyData(
existing: PendingPtyData | undefined,
data: string,
startSeq: number | undefined,
preservesSeq: boolean,
containsBackgroundOutput: boolean
): PendingPtyData {
const nextContainsBackgroundOutput =
existing?.containsBackgroundOutput === true || containsBackgroundOutput
if (!preservesSeq) {
return {
data: (existing?.data ?? '') + data,
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
}
if (!existing) {
return {
data,
...(typeof startSeq === 'number' ? { startSeq } : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
}
const next: PendingPtyData = {
data: existing.data + data,
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
if (typeof existing.startSeq === 'number') {
next.startSeq = existing.startSeq
}
return next
}
function schedulePendingDataFlush(delayMs: number): void {
if (flushTimer) {
return
}
flushTimer = setTimeout(flushPendingData, delayMs)
}
function flushPendingData(): void {
flushTimer = null
if (mainWindow.isDestroyed()) {
pendingData.clear()
rendererInFlightCharsByPty.clear()
rendererInFlightTotalChars = 0
recordPtyRendererDeliveryPressure()
return
}
let writes = 0
for (const [id, pending] of getPendingPtyFlushEntries()) {
if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) {
break
}
if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) {
continue
}
pendingData.delete(id)
const { data } = pending
const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
if (remaining) {
const nextPending: PendingPtyData = { data: remaining }
if (typeof pending.startSeq === 'number') {
nextPending.startSeq = pending.startSeq + chunk.length
}
if (pending.containsBackgroundOutput === true) {
nextPending.containsBackgroundOutput = true
}
pendingData.set(id, nextPending)
}
sendPtyDataToRenderer(
id,
makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput)
)
writes++
}
if (pendingData.size > 0 && writes === 0) {
ackGatedFlushSkipCount++
}
recordPtyRendererDeliveryPressure()
if (pendingData.size > 0 && writes > 0) {
// Why: a background terminal can dump megabytes at once. Yield between
// small IPC slices so keystroke writes are not stuck behind one flush.
schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS)
}
}
const clearFlushTimerIfIdle = (): void => {
if (pendingData.size > 0 || flushTimer === null) {
return
}
clearTimeout(flushTimer)
flushTimer = null
}
const syntheticKillExitPtyIds = new Map<string, NodeJS.Timeout>()
function rememberSyntheticKillExit(id: string): void {
const existing = syntheticKillExitPtyIds.get(id)
if (existing) {
clearTimeout(existing)
}
// Why: some providers can report the real exit after kill has already
// completed; skip only that late duplicate, not a future reused id forever.
const cleanupTimer = setTimeout(() => {
syntheticKillExitPtyIds.delete(id)
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
cleanupTimer.unref?.()
syntheticKillExitPtyIds.set(id, cleanupTimer)
}
function consumeSyntheticKillExit(id: string): boolean {
const cleanupTimer = syntheticKillExitPtyIds.get(id)
if (!cleanupTimer) {
return false
}
clearTimeout(cleanupTimer)
syntheticKillExitPtyIds.delete(id)
return true
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
if (mainWindow.isDestroyed()) {
return
}
// Why: flush any batched data for this PTY before sending the exit event,
// otherwise the last <=8ms of output is silently lost because the renderer
// tears down the terminal on pty:exit before the batch timer fires.
const remaining = pendingData.get(payload.id)
if (remaining) {
sendPtyDataToRenderer(
payload.id,
makePtyDataPayload(
payload.id,
remaining.data,
remaining.startSeq,
remaining.containsBackgroundOutput
)
)
pendingData.delete(payload.id)
}
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
rendererInFlightTotalChars = Math.max(
0,
rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0)
)
rendererInFlightCharsByPty.delete(payload.id)
recordPtyRendererDeliveryPressure()
mainWindow.webContents.send('pty:exit', payload)
}
async function shutdownProviderAndDetectExit(
provider: IPtyProvider,
id: string,
opts: { immediate?: boolean; keepHistory?: boolean }
): Promise<boolean> {
let providerExitObserved = false
const unsubscribe = provider.onExit((payload) => {
if (payload.id === id) {
providerExitObserved = true
}
})
try {
await provider.shutdown(id, opts)
} finally {
unsubscribe()
}
return providerExitObserved
}
// Why: extracted so the "Restart daemon" flow can rebind against the fresh
// adapter after replaceDaemonProvider runs. Both the startup registration
// and the post-restart rebind go through the same code path — no risk of
// drift between the two entry points.
const bindProviderListeners = (): void => {
localDataUnsub?.()
localExitUnsub?.()
// Why: LocalPtyProvider routes data to the runtime via configure().onData,
// but daemon-backed providers don't have configure(). Without this, daemon
// PTY data never reaches the runtime's tail buffer, so terminal.read returns
// empty and agent-detection from raw data never fires. Runtime tails also
// power mobile read/stream, so they must be notified regardless of window
// state.
const isLocalProvider = localProvider instanceof LocalPtyProvider
localDataUnsub = localProvider.onData((payload) => {
const outputSeq = isLocalProvider
? runtime?.getPtyOutputSequence(payload.id)
: runtime?.onPtyData(payload.id, payload.data, Date.now())
const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data)
const preservesSeq = rendererData === payload.data
const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined
if (mainWindow.isDestroyed()) {
// Why: clear the pending flush timer so it doesn't fire after the window
// is gone. Without this, macOS app re-activation leaks orphaned timers
// from the previous window's registration.
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
pendingData.clear()
rendererInFlightCharsByPty.clear()
rendererInFlightTotalChars = 0
recordPtyRendererDeliveryPressure()
return
}
if (rendererData.length === 0) {
return
}
const containsBackgroundOutput =
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
if (containsBackgroundOutput) {
markHiddenRendererResizeOutputDelivered(payload.id)
}
const existing = pendingData.get(payload.id)
const pending = appendPendingPtyData(
existing,
rendererData,
startSeq,
preservesSeq,
containsBackgroundOutput
)
const nextData = pending.data
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
performance.now()
)
if (isInteractiveOutput) {
// Why: user-input echo should not be pinned behind unrelated bulk
// terminal output already handed to the renderer. The reserve is
// bounded, and the per-PTY cap still prevents an active TUI runaway.
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
pendingData.set(payload.id, pending)
recordPtyRendererDeliveryPressure()
return
}
pendingData.delete(payload.id)
clearFlushTimerIfIdle()
// Why: agent TUIs redraw small prompt regions after every keystroke.
// Waiting for the throughput batch timer adds visible input latency.
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
? { seq: pending.startSeq + nextData.length, rawLength: nextData.length }
: {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {})
})
return
}
pendingData.set(payload.id, pending)
recordPtyRendererDeliveryPressure()
if (!flushTimer) {
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
}
})
localExitUnsub = localProvider.onExit((payload) => {
if (consumeSyntheticKillExit(payload.id)) {
return
}
if (!isLocalProvider) {
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(payload.id, payload.code)
}
sendPtyExitToRenderer(payload)
})
}
bindProviderListeners()
rebindProviderListeners = bindProviderListeners
// Why: a persistent ipcMain listener with a request-ID dispatch table
// (instead of one listener per call) so concurrent serialize requests do
// not stack listeners and trip Node's MaxListeners=10 warning. Many
// sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+
// concurrent calls.
type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null
const pendingSerializeRequests = new Map<
string,
{ resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout }
>()
function settleSerializeRequest(requestId: string, result: SerializeResult): void {
const pending = pendingSerializeRequests.get(requestId)
if (!pending) {
return
}
clearTimeout(pending.timeout)
pendingSerializeRequests.delete(requestId)
pending.resolve(result)
}
ipcMain.on(
'pty:serializeBuffer:response',
(
_event,
args: {
requestId?: string
snapshot?: {
data?: unknown
cols?: unknown
rows?: unknown
lastTitle?: unknown
} | null
}
) => {
if (typeof args?.requestId !== 'string') {
return
}
const snapshot = args.snapshot
if (
snapshot &&
typeof snapshot.data === 'string' &&
typeof snapshot.cols === 'number' &&
typeof snapshot.rows === 'number'
) {
const result: { data: string; cols: number; rows: number; lastTitle?: string } = {
data: snapshot.data,
cols: snapshot.cols,
rows: snapshot.rows
}
if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) {
result.lastTitle = snapshot.lastTitle
}
settleSerializeRequest(args.requestId, result)
} else {
settleSerializeRequest(args.requestId, null)
}
}
)
function requestSerializedBuffer(
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
): Promise<SerializeResult> {
if (mainWindow.isDestroyed()) {
return Promise.resolve(null)
}
const requestId = randomUUID()
return new Promise<SerializeResult>((resolve) => {
const timeout = setTimeout(() => {
settleSerializeRequest(requestId, null)
}, 750)
pendingSerializeRequests.set(requestId, { resolve, timeout })
const payload: {
requestId: string
ptyId: string
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
} = { requestId, ptyId }
if (opts) {
payload.opts = opts
}
mainWindow.webContents.send('pty:serializeBuffer:request', payload)
})
}
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
// process and can become orphaned on page reload. Daemon-backed sessions
// survive renderer restarts by design — orphan cleanup would kill them.
clearDidFinishLoadHandler()
if (localProvider instanceof LocalPtyProvider) {
const lp = localProvider
didFinishLoadHandler = () => {
// Why: always advance so the load generation stays monotonic, but skip the
// sweep (and its per-PTY cleanup) on the crash/freeze-recovery reload — it
// would kill live LOCAL PTYs across the single window before session
// restore re-attaches them (#5787). The getter consumes the flag, so the
// next genuine reload still reclaims genuinely-orphaned PTYs.
const generation = lp.advanceGeneration()
if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) {
return
}
const killed = lp.killOrphanedPtys(generation - 1)
for (const { id } of killed) {
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, -1)
}
}
didFinishLoadWebContents = mainWindow.webContents
mainWindow.webContents.on('did-finish-load', didFinishLoadHandler)
}
const assertFolderWorkspacePtyPathUsable = async (
worktreeId: string | undefined
): Promise<void> => {
const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null
if (!store || workspaceScope?.type !== 'folder') {
return
}
const status = await getFolderWorkspacePathStatus(
store,
{ scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId },
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
}
const resolveGuardedPtySpawnCwd = (
worktreeId: string | undefined,
cwd: string | undefined,
connectionId?: string | null
): string | undefined =>
resolveTerminalStartupCwdForWorkspace({
workspaceId: worktreeId,
requestedCwd: cwd,
resolveFolderWorkspacePath: (folderWorkspaceId) =>
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath,
// Why: realpath only makes sense on the local filesystem; SSH worktree
// paths live on the remote host.
canonicalizePath: localTerminalCwdCanonicalizer(connectionId)
})
// Why: the runtime controller must route through getProviderForPty() so that
// CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs.
// Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs.
runtime?.setPtyController({
spawn: async (args) => {
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
if (startupPromise) {
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
// Why: runtime-created terminals do not carry renderer-computed
// projectRuntime, so resolve from worktreeId to honor project Windows runtime.
const terminalRuntimeOptions =
process.platform === 'win32' && !args.connectionId
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: undefined,
settings: getSettings?.(),
projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, args.worktreeId),
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
})
: { shellOverride: undefined, terminalWindowsWslDistro: null }
const daemonShellOverride = terminalRuntimeOptions.shellOverride
const codexSelectionTarget = getCodexSelectionTargetForPty(
daemonShellOverride,
cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const claudeAuth =
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
throw new Error(
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
)
}
const isDaemonHostSpawn =
!args.connectionId &&
!(provider instanceof LocalPtyProvider) &&
!routesFreshSpawnsToLocalProvider(provider)
const requestedSessionId = args.sessionId?.trim()
const sessionId =
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
const effectiveSessionRelayId =
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
const effectiveSessionAppId =
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
const isMintedSessionId = requestedSessionId === undefined && isDaemonHostSpawn
const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true
let hostSessionBinding: {
store: NonNullable<typeof store>
worktreeId: string
tabId: string
leafId: string
} | null = null
if (shouldPersistHostSessionBinding) {
if (
!store ||
typeof args.worktreeId !== 'string' ||
typeof args.tabId !== 'string' ||
typeof args.leafId !== 'string' ||
!isTerminalLeafId(args.leafId)
) {
throw new Error(
'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId'
)
}
hostSessionBinding = {
store,
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId
}
}
const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env)
let env: Record<string, string> | undefined = claudeAuth
? { ...sshScopedEnv, ...claudeAuth.envPatch }
: sshScopedEnv
const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
)
: null
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
!selectedCodexHomePath
if (isDaemonHostSpawn && sessionId) {
if (!isSafePtySessionId(sessionId, app.getPath('userData'))) {
throw new Error('Invalid PTY session id')
}
env = buildPtyHostEnv(sessionId, env ?? {}, {
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
}
const authEnvToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
cwd,
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
)
if (skipCodexHomeEnv) {
spawnOptions.envToDelete = mergePtyEnvDeletions(
spawnOptions.envToDelete,
CODEX_HOME_ENV_KEYS
)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
if (args.command !== undefined) {
spawnOptions.command = args.command
}
if (args.commandDelivery !== undefined) {
spawnOptions.commandDelivery = args.commandDelivery
}
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
if (args.worktreeId !== undefined) {
spawnOptions.worktreeId = args.worktreeId
}
if (sessionId !== undefined) {
spawnOptions.sessionId = sessionId
ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows })
}
if (process.platform === 'win32' && !args.connectionId) {
spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride
spawnOptions.terminalWindowsWslDistro =
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
const materializedPaneKey = hostSessionBinding
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
: null
const existingPaneSpawn = materializedPaneKey
? paneSpawnReservationsByPaneKey.get(materializedPaneKey)
: undefined
if (existingPaneSpawn) {
return await existingPaneSpawn.promise
}
const paneSpawnReservation = materializedPaneKey
? reservePaneSpawn(materializedPaneKey)
: null
let result: PtySpawnResult
try {
try {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
result = await provider.spawn(spawnOptions)
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
ptySizes.delete(effectiveSessionAppId)
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
) {
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
if (isMintedSessionId && sessionId !== undefined) {
clearProviderPtyState(sessionId)
}
throw spawnError
} finally {
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
const relayResultId = getRelayPtyId(args.connectionId, result.id)
const persistSshLease = (): void => {
if (!store || !args.connectionId) {
return
}
// Why: workspace-session bindings keep app-facing PTY ids for hydration,
// while SSH leases keep relay ids for remote lease reconciliation.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId)
? { leafId: args.leafId }
: {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (!hostSessionBinding) {
persistSshLease()
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) {
ptySizes.delete(effectiveSessionAppId)
}
if (hostSessionBinding) {
try {
hostSessionBinding.store.persistPtyBinding({
worktreeId: hostSessionBinding.worktreeId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id,
...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
deletePtyOwnership(result.id)
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(result.id)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
persistSshLease()
}
if (args.preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle)
}
if (args.worktreeId) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
}
// Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler,
// so record their spawn-time paneKey here too. Synthetic hook titles and
// paneKey-scoped cache cleanup both depend on this reverse lookup.
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
if (!args.connectionId) {
registerPty({
ptyId: result.id,
worktreeId: args.worktreeId ?? null,
sessionId: sessionId ?? null,
paneKey,
pid:
typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0
? result.pid
: null
})
}
const response = { id: result.id }
return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: once the reservation is created, any later throw — spawn
// failure, persist failure, or a post-spawn helper such as
// registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err)
throw err
}
},
write: (ptyId, data) => {
const provider = getProviderForPty(ptyId)
try {
provider.write(ptyId, data)
return true
} catch {
return false
}
},
kill: (ptyId) => {
let provider: IPtyProvider
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
try {
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: runtime/CLI close can target a detached SSH PTY after its
// provider was unregistered. Tombstone the lease so reconnect does
// not revive a terminal the user explicitly closed.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
}
return false
}
// Why: shutdown() is async but the PtyController interface is sync. Defer
// cleanup until shutdown resolves so transient SSH/daemon failures don't
// hide a still-running remote process or local daemon session.
//
// Same synthetic-exit contract as the renderer pty:kill handler: when the
// provider emitted its own exit during shutdown, the exit listener already
// delivered runtime + renderer exits — synthesizing again would double-fire.
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
.then((providerExitObserved) => {
finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(ptyId, -1)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
})
.catch((err) => {
if (isPtyAlreadyGoneError(err)) {
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return
}
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
// Why: callers of controller.kill must observe a kill→exit pair so
// runtime tail buffers close and agents stop treating the pane as
// live. Preserve provider/lease state so a retry can still target
// the remote PTY if it survived the transient failure.
runtime?.onPtyExit(ptyId, -1)
})
return true
},
stopAndWait: async (ptyId, opts) => {
let provider: IPtyProvider
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
try {
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: an absent SSH provider means there is no live target left to
// await, but the relay lease must still be tombstoned.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
}
return false
}
let providerExitObserved = false
try {
providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, {
immediate: true,
keepHistory: opts?.keepHistory ?? false
})
} catch (err) {
if (!isPtyAlreadyGoneError(err)) {
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
return false
}
}
try {
if (!(await verifyPtyStopped(provider, ptyId, opts))) {
return false
}
} catch (err) {
console.warn(
`[pty] Failed to verify PTY ${ptyId} stopped: ${
err instanceof Error ? err.message : String(err)
}`
)
return false
}
finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(ptyId, -1)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
return true
},
getForegroundProcess: async (ptyId) => {
try {
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
} catch {
return null
}
},
getCwd: async (ptyId) => {
try {
const cwd = await getProviderForPty(ptyId).getCwd(ptyId)
return cwd || null
} catch {
return null
}
},
hasChildProcesses: async (ptyId) => {
try {
return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
} catch {
return false
}
},
clearBuffer: async (ptyId) => {
// Why: desktop xterm owns local scrollback, while daemon/SSH providers
// own their own retained buffers. Clear both surfaces so mobile
// resubscribe snapshots do not resurrect cleared history.
mainWindow.webContents.send('pty:clearBuffer:request', { ptyId })
try {
await getProviderForPty(ptyId).clearBuffer(ptyId)
} catch {
/* best effort: renderer clear still handles local PTYs */
}
},
listProcesses: async () => {
const providerSessions = await Promise.all([
localProvider.listProcesses(),
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
])
return providerSessions.flat()
},
serializeBuffer: (ptyId, opts) => {
// Why: mobile xterm must start from the desktop xterm's exact screen
// state and dimensions before live TUI chunks can render correctly.
return requestSerializedBuffer(ptyId, opts)
},
hasRendererSerializer: (ptyId) => {
// Why: the runtime needs a synchronous probe so it can decide whether to
// skip the daemon-snapshot seed (the renderer will hydrate it) or run the
// seed (no renderer authoritative for this PTY). A registry write happens
// when the renderer calls registerPtySerializer; we check via the same
// pendingByPaneKey + ptyId pairing that the cooperation gate uses.
return rendererSerializerByPtyId.has(ptyId)
},
getSize: (ptyId) => ptySizes.get(ptyId) ?? null,
resize: (ptyId, cols, rows) => {
try {
getProviderForPty(ptyId).resize(ptyId, cols, rows)
ptySizes.set(ptyId, { cols, rows })
return true
} catch {
return false
}
}
})
// ─── IPC Handlers (thin dispatch layer) ─────────────────────────
function normalizeSnapshotScrollbackRows(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.max(0, Math.min(50_000, Math.floor(value)))
}
ipcMain.handle(
'pty:getMainBufferSnapshot',
async (
_event,
args: { id?: unknown; opts?: { scrollbackRows?: unknown } }
): Promise<{
data: string
cols: number
rows: number
cwd?: string | null
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
return null
}
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
try {
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
} catch {
return null
}
}
)
ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => {
return getPtyRendererDeliveryDebugSnapshot()
})
ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => {
resetPtyRendererDeliveryDebug()
})
ipcMain.handle(
'pty:spawn',
async (
_event,
args: {
cols: number
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
sessionId?: string
shellOverride?: string
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: {
foreground?: unknown
background?: unknown
}
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
// ptyId) binding before pty:spawn returns. Only the renderer's
// user-typing-Ctrl+T daemon-host path threads these; mobile/runtime
// CLI/SSH spawns leave them undefined and the main-side guard
// short-circuits.
tabId?: string
leafId?: string
// Why: telemetry-plan.md§Agent launch semantics. The renderer
// threads what Orca was *asked* to launch through this field; main
// fires `agent_started` only after `provider.spawn` resolves. Loose
// typing on the IPC boundary because the main-side schema
// validator is the single enforcement point — `track()` will drop
// the event if any field is outside its closed enum.
telemetry?: {
agent_kind?: unknown
launch_source?: unknown
request_kind?: unknown
}
}
) => {
const spawnTiming = createPtySpawnTiming()
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
if (startupPromise) {
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
spawnTiming.mark('preflight')
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
const terminalRuntimeOptions =
process.platform === 'win32' && !args.connectionId
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: args.shellOverride,
settings: getSettings?.(),
projectRuntime: args.projectRuntime,
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
})
: { shellOverride: args.shellOverride, terminalWindowsWslDistro: null }
const initialShellOverride = terminalRuntimeOptions.shellOverride
const initialSelectionTarget = getCodexSelectionTargetForPty(
initialShellOverride,
cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const claudeAuth =
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
spawnTiming.mark('auth')
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
throw new Error(
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
)
}
// Why: the daemon-backed provider replaces LocalPtyProvider and therefore
// never runs its buildSpawnEnv closure. We must assemble the same
// host-local env (OpenCode plugin, agent-hook server, Pi/OMP managed
// extensions, Codex home, dev CLI overrides, GitHub attribution shims)
// here so both spawn paths behave identically. buildPtyHostEnv is the
// shared helper that encapsulates the full set of injections and guards.
//
// Safety: skip the entire injection when a remote (SSH) connection is in
// play. Every injection here is either host-loopback (the agent-hook
// server binds 127.0.0.1, so shipping its token to an SSH host would
// leak a loopback secret for no functional benefit) or a path on the
// local filesystem (OpenCode plugin dir, Pi/OMP extension paths, Codex
// home, dev CLI bin, attribution shim dir) that would resolve to
// nothing — or something misleading — on the remote machine.
const isDaemonHostSpawn =
!args.connectionId &&
!(provider instanceof LocalPtyProvider) &&
!routesFreshSpawnsToLocalProvider(provider)
// Why: daemon host-env setup needs a stable id BEFORE provider.spawn so
// provider hooks and legacy Pi overlay cleanup can run in buildPtyHostEnv.
// DaemonPtyAdapter.doSpawn mints an id the same way when sessionId is
// absent — lifting the mint here gives pty.ts the id up-front without
// changing daemon semantics (the daemon still honors opts.sessionId ?? mint()).
//
// Note: the sessionId is STABLE across daemon restarts by design —
// DaemonPtyAdapter.reconcileOnStartup reuses it so that users' live
// shells survive crashes. Do NOT "simplify" id allocation back to a
// fresh UUID per spawn; that would orphan reconnectable terminal state.
// Why: only state for ids we minted in THIS request should be cleared on
// spawn failure. If the caller supplied args.sessionId it may refer to
// an existing PTY whose state (OpenCode hooks, legacy Pi overlay cleanup,
// agent-hook pane caches) we must not clobber on a retry/attach failure.
const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn
const effectiveSessionId =
args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
const effectiveSessionAppId =
effectiveSessionId !== undefined
? getAppPtyId(args.connectionId, effectiveSessionId)
: undefined
const effectiveSessionRelayId =
effectiveSessionId !== undefined
? getRelayPtyId(args.connectionId, effectiveSessionId)
: undefined
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
const preSpawnStartupTerminalColorReplyPtyId =
startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined
? (effectiveSessionAppId ?? effectiveSessionId)
: null
// Why: the renderer sets pane env for SSH too. Only forward it to the
// remote when the relay hook path is enabled; otherwise a newer relay
// could emit statuses this Orca build is not prepared to route.
const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env)
const baseEnvWithAuth = claudeAuth
? { ...sshSourceEnv, ...claudeAuth.envPatch }
: sshSourceEnv
const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY
const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey)
const verifiedPaneKey =
parsedSpawnPaneKey &&
typeof args.tabId === 'string' &&
args.tabId === parsedSpawnPaneKey.tabId &&
args.leafId === parsedSpawnPaneKey.leafId
? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId)
: null
const verifiedLeafId =
verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null
const metadataLeafId =
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
const metadataPaneKey =
typeof args.tabId === 'string' &&
args.tabId.length > 0 &&
args.tabId.length <= 512 &&
metadataLeafId
? makePaneKey(args.tabId, metadataLeafId)
: null
const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey)
const migrationUnsupportedPaneKey =
legacySpawnPaneKey &&
typeof args.tabId === 'string' &&
args.tabId === legacySpawnPaneKey.tabId &&
typeof args.leafId === 'string' &&
isTerminalLeafId(args.leafId)
? makePaneKey(args.tabId, args.leafId)
: null
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
const shouldRefreshAgentTeamsEnv =
!args.connectionId &&
runtime !== undefined &&
stablePaneKey !== null &&
shouldRefreshNativeClaudeAgentTeamsEnv({
command: args.command,
launchConfig: args.launchConfig
})
let effectiveLaunchConfig = args.launchConfig
const shouldPreAllocateTerminalHandle =
runtime !== undefined &&
((!(provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(provider)) ||
shouldRefreshAgentTeamsEnv)
const preAllocatedHandle = shouldPreAllocateTerminalHandle
? runtime.createPreAllocatedTerminalHandle()
: null
if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) {
// Why: native Agent Teams team ids/tokens are process-local. A sleeping
// record preserves the user's native launch shape, but the team env
// itself must be regenerated for the new leader PTY.
const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({
handle: preAllocatedHandle,
baseEnv: baseEnv ?? {}
})
baseEnv = {
...baseEnv,
...prepared.env
}
if (args.launchConfig) {
effectiveLaunchConfig = {
...args.launchConfig,
agentEnv: {
...args.launchConfig.agentEnv,
...prepared.env
}
}
}
}
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv
? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
: undefined
if (baseEnv && stablePaneKey) {
baseEnv.ORCA_PANE_KEY = stablePaneKey
if (typeof args.tabId === 'string') {
baseEnv.ORCA_TAB_ID = args.tabId
} else if (!args.connectionId) {
delete baseEnv.ORCA_TAB_ID
}
if (typeof args.worktreeId === 'string') {
baseEnv.ORCA_WORKTREE_ID = args.worktreeId
} else if (!args.connectionId) {
delete baseEnv.ORCA_WORKTREE_ID
}
} else if (baseEnv) {
// Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the
// key proven to match this spawn's tab+leaf may leave the IPC boundary.
delete baseEnv.ORCA_PANE_KEY
delete baseEnv.ORCA_TAB_ID
delete baseEnv.ORCA_WORKTREE_ID
delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN
}
const validatedPaneKey = stablePaneKey
// Why: SSH can strip ORCA_PANE_KEY when remote hooks are disabled; the
// IPC tab/leaf metadata still names the pane and matches runtime fallback.
const reservationPaneKey = metadataPaneKey ?? validatedPaneKey
const validatedLeafId = verifiedLeafId ?? metadataLeafId
let env: Record<string, string> | undefined = baseEnv
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
const codexSelectionTarget = getCodexSelectionTargetForPty(
effectiveShellOverride,
cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
)
: null
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
!selectedCodexHomePath
if (isDaemonHostSpawn) {
if (effectiveSessionId === undefined) {
// Should be unreachable: the expression above returns a string when
// isDaemonHostSpawn is true. Defense-in-depth in case future edits
// break this invariant.
throw new Error('Invariant violation: daemon spawn without sessionId')
}
const sessionIdForEnv = effectiveSessionId
// Why: this id still reaches filesystem side-effects for provider
// hook state and stale pre-migration Pi overlay cleanup; reject
// traversal/path separators before a crafted IPC payload can escape
// the expected roots.
if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) {
throw new Error('Invalid PTY session id')
}
// Why: clone before mutating so we don't leak injections back into
// args.env (which the renderer may reuse for other IPC calls).
env = { ...baseEnv }
try {
buildPtyHostEnv(sessionIdForEnv, env, {
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
} catch (err) {
// Why: buildPtyHostEnv has filesystem side-effects (Pi/OMP managed
// extension installation). If it throws before we reach provider.spawn,
// clear per-PTY state so the next attempt starts clean.
//
// Only sweep state for ids we MINTED in this request — caller-
// supplied ids may refer to existing PTYs whose overlay/hook state
// must not be clobbered by a transient overlay-mkdir failure on a
// retry/attach path.
if (isMintedSessionId) {
clearProviderPtyState(sessionIdForEnv)
}
throw err
}
}
spawnTiming.mark('host_env')
const spawnEnv = preAllocatedHandle
? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle }
: env
const envToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(
mergePtyEnvDeletions(
mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []),
agentTeamsEnvToDelete ?? []
),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : []
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
)
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
cwd,
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (combinedEnvToDelete) {
spawnOptions.envToDelete = combinedEnvToDelete
}
if (args.command !== undefined) {
spawnOptions.command = args.command
}
if (args.commandDelivery !== undefined) {
spawnOptions.commandDelivery = args.commandDelivery
}
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
if (args.worktreeId !== undefined) {
spawnOptions.worktreeId = args.worktreeId
}
if (effectiveSessionId !== undefined) {
spawnOptions.sessionId = effectiveSessionId
}
// Why: on Windows, fall back to the persisted default-shell setting
// when the renderer didn't send a per-tab override. Without this, the
// daemon path ignores the user's "Default Shell" preference entirely —
// it just calls resolvePtyShellPath(env) which reads COMSPEC (cmd.exe)
// or falls back to PowerShell. The LocalPtyProvider already consults
// getWindowsShell(); this mirrors that on the daemon path so users who
// set WSL as default actually get WSL when pressing Ctrl+T.
if (effectiveShellOverride !== undefined) {
spawnOptions.shellOverride = effectiveShellOverride
}
if (effectiveSessionId !== undefined) {
// Why: daemon PTYs can emit prompt/startup bytes before spawn()
// resolves. Runtime headless snapshots need the real pane geometry
// for those early bytes; otherwise they default to 80x24 and wrap TUIs.
ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, {
cols: args.cols,
rows: args.rows
})
}
if (process.platform === 'win32' && !args.connectionId) {
// Why: the renderer only models PowerShell as one shell family. Thread
// the persisted implementation choice through spawnOptions so both the
// in-process and daemon-backed PTY paths can resolve the same effective
// executable without inventing a fourth top-level shell.
spawnOptions.terminalWindowsWslDistro =
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
const existingPaneSpawn = reservationPaneKey
? paneSpawnReservationsByPaneKey.get(reservationPaneKey)
: undefined
if (existingPaneSpawn) {
return await existingPaneSpawn.promise
}
const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null
let result: PtySpawnResult
try {
try {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.add(preAllocatedHandle)
}
if (preSpawnStartupTerminalColorReplyPtyId && startupTerminalColorQueryReplyColors) {
// Why: Codex probes OSC 10/11 with a 100 ms timeout and daemon PTYs
// can emit that query before spawn() resolves to the renderer.
registerStartupTerminalColorQueryReplies(
preSpawnStartupTerminalColorReplyPtyId,
startupTerminalColorQueryReplyColors
)
}
spawnTiming.mark('options')
result = await provider.spawn(spawnOptions)
spawnTiming.mark('provider_spawn')
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (preSpawnStartupTerminalColorReplyPtyId) {
clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId)
}
if (effectiveSessionAppId !== undefined) {
ptySizes.delete(effectiveSessionAppId)
}
if (
args.connectionId &&
effectiveSessionRelayId !== undefined &&
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
) {
// Why: expired remote reattach means the relay has already dropped
// the backing PTY. Clear the durable lease so later session writes
// cannot restore the stale pane binding.
if (effectiveSessionAppId !== undefined) {
clearProviderPtyState(effectiveSessionAppId)
deletePtyOwnership(effectiveSessionAppId)
}
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
}
// Why: if buildPtyHostEnv materialized provider state for this minted
// id but provider.spawn failed, that state would otherwise leak.
if (isMintedSessionId && effectiveSessionId !== undefined) {
clearProviderPtyState(effectiveSessionId)
}
// Why: telemetry-plan.md§agent_error — when the renderer threaded
// agent_kind through args.telemetry, attribute the error to that agent.
// Otherwise fall back to sniffing the command for `claude` (the one
// agent the main process can identify on its own via the existing
// `isClaudeLaunchCommand` regex used for auth gating). Bare-shell
// catches and unknown-agent catches without renderer telemetry remain
// unattributed. The event still emits with a classified `error_class`;
// raw error messages are dropped at the telemetry validator boundary.
const rendererAgentKindParse =
args.telemetry?.agent_kind !== undefined
? agentKindSchema.safeParse(args.telemetry.agent_kind)
: null
const errorAgentKind = rendererAgentKindParse?.success
? rendererAgentKindParse.data
: isClaudeLaunch
? ('claude-code' as const)
: null
if (errorAgentKind) {
const classified = classifyError(spawnError)
track('agent_error', {
agent_kind: errorAgentKind,
error_class: classified.error_class,
...getCohortAtEmit()
})
}
throw spawnError
} finally {
if (preAllocatedHandle) {
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
spawnTiming.log(result.id, {
daemon: isDaemonHostSpawn,
reattach: result.isReattach ?? false
})
ptyOwnership.set(result.id, args.connectionId ?? null)
if (startupTerminalColorQueryReplyColors) {
if (result.isReattach) {
if (preSpawnStartupTerminalColorReplyPtyId) {
clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId)
}
} else if (preSpawnStartupTerminalColorReplyPtyId) {
moveStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId, result.id)
} else {
registerStartupTerminalColorQueryReplies(
result.id,
startupTerminalColorQueryReplyColors
)
}
}
const relayResultId = getRelayPtyId(args.connectionId, result.id)
if (store && args.connectionId) {
// Why: remote PTYs live in the SSH relay grace window after Orca
// detaches. Persist their IDs immediately so reconnect can reattach
// instead of treating the tab as a fresh shell.
store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(validatedLeafId ? { leafId: validatedLeafId } : {}),
state: 'attached',
lastAttachedAt: Date.now()
})
}
if (preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
}
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
// Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217)
// for local daemon PTYs and the equivalent remote-relay race for SSH.
// The renderer's debounced session writer runs in parallel for every
// other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId)
// binding synchronously so a force-quit in the ~450 ms debounce window
// cannot orphan either daemon history or a remote relay PTY lease.
if (
(isDaemonHostSpawn || args.connectionId) &&
store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
validatedLeafId !== null
) {
try {
store.persistPtyBinding({
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id,
...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
if (!result.isReattach) {
try {
await provider.shutdown(result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(result.id)
deletePtyOwnership(result.id)
}
if (!result.isReattach && args.connectionId && store) {
store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
}
}
// Why: pre-signal cooperation gate — when the renderer has declared it
// will own the serializer for this paneKey, suppress the daemon-snapshot
// seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer)
// is the sole authority. The pre-signal is keyed on paneKey because at
// spawn time the renderer doesn't yet know the new ptyId. See
// docs/mobile-prefer-renderer-scrollback.md.
const rendererPreSignaled = validatedPaneKey
? pendingByPaneKey.has(validatedPaneKey)
: false
const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id)
// Why: capture the pending gen at spawn time so teardown for THIS PTY
// only settles its own generation. A remount that replaces the entry
// with a new gen must not be stomped by the old PTY's teardown.
if (validatedPaneKey && rendererPreSignaled) {
const pending = pendingByPaneKey.get(validatedPaneKey)
if (pending) {
ptyPendingGenByPtyId.set(result.id, pending.gen)
}
}
// Why: hydrate the runtime's headless emulator with the adapter's
// restore data BEFORE registerPty so any live PTY data that arrives
// concurrently lands on top of the seed instead of replacing it. Mobile
// subscribers then see the same scrollback the desktop xterm received
// via coldRestore/snapshot. Without this, mobile snapshots after a
// daemon-restored attach contain only bytes emitted since the relaunch
// and the prior agent output silently disappears.
//
// Skip when the renderer is or will be authoritative for this PTY:
// its hydration path will seed the emulator from xterm's live buffer,
// which is richer than the daemon snapshot.
if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
const seedSize =
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
result.coldRestore.scrollback.length > 0
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
})
}
}
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
// Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned
// spawn (see pty-connection.ts). Recording the mapping here lets
// clearProviderPtyState clear the agent-hooks server's per-paneKey
// caches when the PTY exits.
// Why: args.env arrives as untrusted JSON over IPC — the static
// Record<string, string> type is not actually enforced at the boundary.
// Narrow to a bounded string so malformed or oversized values cannot
// pollute ptyPaneKey or the downstream clearPaneState call.
const rememberedPaneKey = validatedPaneKey
? rememberPaneKeyForPty(result.id, validatedPaneKey)
: null
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
legacySpawnPaneKey.paneKey,
migrationUnsupportedPaneKey,
result.id
)
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
} else if (validatedPaneKey) {
if (!result.isReattach) {
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
}
}
// Why: register local PTYs (connectionId falsy) with the memory
// collector so it can walk each PTY's process subtree and attribute
// memory back to its worktree. SSH PTYs execute remotely and their
// process tree is not visible to our local `ps`, so we skip them.
if (!args.connectionId) {
// Why: providers publish the OS pid on the spawn result (both
// LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps
// the memory module from reaching back into ipc/pty on a hot path, and
// works uniformly whether the PTY is hosted in-process or by the
// daemon subprocess.
const spawnedPid = result.pid ?? null
// Why: args.worktreeId and args.sessionId arrive as untrusted IPC
// payload strings — the static type is not enforced at the boundary.
// Narrow them to bounded strings here to match the paneKey defense
// above so malformed or oversized values cannot pollute registerPty's
// maps or downstream memory-attribution lookups.
registerPty({
ptyId: result.id,
worktreeId:
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
? args.worktreeId
: null,
sessionId:
typeof args.sessionId === 'string' &&
args.sessionId.length > 0 &&
args.sessionId.length <= 256
? args.sessionId
: null,
paneKey: rememberedPaneKey,
pid:
typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0
? spawnedPid
: null
})
}
// Why: telemetry-plan.md§Agent launch semantics — fire `agent_started`
// only after `provider.spawn` resolved. The renderer threads
// `args.telemetry` through the spawn IPC for every launch we want to
// attribute; bare-shell tabs (no agent) leave the field undefined and
// do not produce an event. Each field is parsed against its closed
// enum here so a malformed renderer payload (or a spoofed IPC) does
// not poison the event — `safeParse` failure drops that field, and
// if any required field is missing we skip the event entirely. The
// main-side `track()` validator re-runs the schema on the full
// payload as a second defense-in-depth check.
if (args.telemetry) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
}
const response = {
...result,
...(!result.isReattach && effectiveLaunchConfig
? { launchConfig: effectiveLaunchConfig }
: {})
}
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: once the reservation is created, any later throw —
// spawn failure, persist failure, or a post-spawn helper such as
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err)
throw err
}
}
)
const writePtyProviderInputWithinLimit = (
provider: IPtyProvider,
id: string,
data: string
): boolean | Promise<boolean> => {
const chunks = iterateTerminalInputChunks(data)
const first = chunks.next()
if (first.done) {
provider.write(id, data)
return true
}
const second = chunks.next()
if (second.done) {
provider.write(id, first.value)
return true
}
return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value)
}
const writePtyProviderInput = (
provider: IPtyProvider,
id: string,
data: string
): boolean | Promise<boolean> => {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (typeof tooLarge === 'boolean') {
return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data)
}
return tooLarge
.then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data)))
.catch(() => false)
} catch {
return false
}
}
const writePtyProviderInputChunks = async (
provider: IPtyProvider,
id: string,
chunks: Iterator<string>,
firstChunk: string,
secondChunk: string
): Promise<boolean> => {
try {
let chunk: IteratorResult<string> = { done: false, value: firstChunk }
let nextChunk: IteratorResult<string> = { done: false, value: secondChunk }
while (!chunk.done) {
provider.write(id, chunk.value)
if (!nextChunk.done) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
chunk = nextChunk
nextChunk = chunks.next()
}
return true
} catch {
return false
}
}
type PtyWritePayload = { id: string; data: string }
const isPtyWritePayload = (value: unknown): value is PtyWritePayload =>
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
(value as { id: string }).id.length > 0 &&
typeof (value as { data?: unknown }).data === 'string'
const isPtyWriteEventFromMainWindow = (
event: IpcMainEvent | IpcMainInvokeEvent,
mainWebContents: WebContents
): boolean =>
event.sender === mainWebContents &&
!mainWindow.isDestroyed() &&
!(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed())
const writePtyInput = (args: PtyWritePayload): boolean | Promise<boolean> => {
// Why: defense-in-depth for the mobile-presence lock. The renderer's
// xterm.onData guard already drops desktop keystrokes when mobile is
// driving, but a stale view between the main-side state flip and the
// IPC arriving in the renderer can let one keystroke slip through.
// This server-side check catches it. See
// docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined
if (!provider) {
return false
}
try {
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
if (visibleRendererPtys.has(args.id)) {
clearHiddenRendererResizeOutput(args.id)
}
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
}
}
const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise<boolean> => {
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
// Why: the acknowledgement is used to infer Ctrl+C/Escape actually reached
// the local PTY. SSH providers are fire-and-forget relay notifications, so
// they cannot truthfully acknowledge until the relay protocol grows a write
// request/response.
if (ptyOwnership.get(args.id) !== null) {
return false
}
const provider = tryGetProviderForPty(args.id)
if (!provider?.hasPty?.(args.id)) {
return false
}
try {
const now = performance.now()
lastInputAtByPty.set(args.id, now)
interactiveOutputCharsByPty.set(args.id, 0)
if (visibleRendererPtys.has(args.id)) {
clearHiddenRendererResizeOutput(args.id)
}
return writePtyProviderInput(provider, args.id, args.data)
} catch {
return false
}
}
ipcMain.on('pty:write', (event, args: unknown) => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
return
}
writePtyInput(args)
})
ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
return false
}
return writePtyInputAccepted(args)
})
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
// Using ipcMain.on (not .handle) halves IPC traffic by avoiding the
// empty acknowledgement message back to the renderer.
ipcMain.removeAllListeners('pty:resize')
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
// Why: after a desktop-fit override change, the desktop renderer's
// re-render cascade 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. Suppressing ALL pty:resize during
// this window prevents the cascade from corrupting PTY dimensions.
if (runtime?.isResizeSuppressed()) {
return
}
// Why: presence-lock defense-in-depth. While mobile is driving,
// desktop-side resizes (auto-fit on window resize, split drag) must
// not reach the PTY. The renderer guard checks the driver state too,
// but this is the load-bearing layer because the renderer mirror lags
// by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles
// the safeFit cascade after take-back; this driver check handles the
// ongoing locked state. See docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
return
}
const provider = tryGetProviderForPty(args.id)
if (!provider) {
return
}
const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id)
if (markedHiddenResizeOutput) {
// Why: alternate-screen TUIs repaint on SIGWINCH. If that hidden repaint
// is read after the user switches back, it must not masquerade as live
// foreground output and overwrite the correctly-sized screen.
pendingHiddenRendererResizeOutputPtys.add(args.id)
deliveredHiddenRendererResizeOutputPtys.delete(args.id)
} else if (visibleRendererPtys.has(args.id)) {
// Why: after the stale hidden-resize repaint has been observed, the
// renderer's visible resize pulse owns the next repaint.
clearDeliveredHiddenRendererResizeOutput(args.id)
}
try {
provider.resize(args.id, args.cols, args.rows)
} catch {
if (markedHiddenResizeOutput) {
pendingHiddenRendererResizeOutputPtys.delete(args.id)
}
return
}
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
runtime?.onExternalPtyResize(args.id, args.cols, args.rows)
})
// Why: pty:reportGeometry is a measurement-only sibling of pty:resize.
// pty:resize means "I want the PTY at this size" (a write/intent — gated
// by mobile-driver and cascade suppress). pty:reportGeometry means "the
// desktop pane I'm rendering currently measures this many cells" (a
// read/observation). Mobile-fit hold needs the latter even while the
// former is intentionally blocked: when a previously-hidden desktop
// tab becomes visible while a phone is driving, the server has no way
// to learn the real desktop dims, and resolveDesktopRestoreTarget
// returns the stale spawn default (e.g. 80×24) on Take Back. Splitting
// the channels keeps each guard simple — pty:resize keeps its mobile-
// driver gate; pty:reportGeometry never resizes the PTY, only refreshes
// the restore-target cache. See docs/mobile-fit-hold.md.
ipcMain.removeAllListeners('pty:reportGeometry')
ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => {
runtime?.recordRendererGeometry(args.id, args.cols, args.rows)
})
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore
// cache after the renderer has consumed the data. No-op for non-daemon providers.
ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => {
const provider = tryGetProviderForPty(args.id)
if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') {
provider.ackColdRestore(args.id)
}
})
// Why: renderer ACKs bound main→renderer terminal delivery without stopping
// PTY ingestion. Agent/status consumers still see every chunk through the
// provider/runtime path while background renderer writes wait their turn.
ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => {
const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0
const current = rendererInFlightCharsByPty.get(args.id) ?? 0
const acknowledged = Math.min(current, charCount)
const next = Math.max(0, current - charCount)
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
if (next === 0) {
rendererInFlightCharsByPty.delete(args.id)
} else {
rendererInFlightCharsByPty.set(args.id, next)
}
tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged)
recordPtyRendererDeliveryPressure()
if (pendingData.size > 0 && !flushTimer) {
schedulePendingDataFlush(0)
}
})
ipcMain.removeAllListeners('pty:setActiveRendererPty')
ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
// Why: this is a renderer scheduling hint only. PTY reads, runtime state,
// and notifications continue for inactive terminals; active panes merely
// get first chance at the bounded renderer output reserve.
if (args.active) {
activeRendererPtys.add(args.id)
} else {
activeRendererPtys.delete(args.id)
}
if (pendingData.size > 0 && !flushTimer) {
schedulePendingDataFlush(0)
}
})
ipcMain.removeAllListeners('pty:setRendererPtyVisible')
ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
// Why: data produced while no renderer can see this PTY must keep that origin
// through batching, even if the user switches back before the flush lands.
rendererVisibilityKnownPtys.add(args.id)
if (args.visible) {
visibleRendererPtys.add(args.id)
} else {
visibleRendererPtys.delete(args.id)
}
})
ipcMain.removeAllListeners('pty:signal')
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
tryGetProviderForPty(args.id)
?.sendSignal(args.id, args.signal)
.catch(() => {})
})
ipcMain.removeAllListeners('pty:clearBuffer')
ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
// Why: the renderer already cleared its own xterm buffer. This clears the
// PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer)
// so the next prompt repaint doesn't land at a stale cursor row.
tryGetProviderForPty(args.id)
?.clearBuffer(args.id)
.catch(() => {})
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
})
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
const connectionId = ownedConnectionId ?? parsedSshId?.connectionId
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id)
if (!provider && connectionId) {
// Why: detached SSH PTYs intentionally keep ownership after their
// provider is unregistered; hydrated app-scoped ids can also arrive
// before ownership is rebuilt. Tombstone instead of falling back local.
finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1)
rememberSyntheticKillExit(args.id)
sendPtyExitToRenderer({ id: args.id, code: -1 })
return
}
const shutdownProvider = provider ?? getProviderForPty(args.id)
let providerExitObserved = false
try {
providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, {
immediate: true,
keepHistory: args.keepHistory ?? false
})
} catch (err) {
if (!isPtyAlreadyGoneError(err)) {
// Why: a failed SSH shutdown can leave the remote process alive in
// the relay grace window; daemon failures have the same risk locally.
// Keep ownership/lease state so the user can retry.
throw err
}
/* session already dead — cleanup below handles the rest */
}
// Why: some shutdown paths do not emit onExit through the provider listener.
// Explicit cleanup is idempotent and covers already-dead PTYs.
finishPtyShutdown(args.id, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(args.id, -1)
rememberSyntheticKillExit(args.id)
sendPtyExitToRenderer({ id: args.id, code: -1 })
}
})
ipcMain.handle(
'pty:listSessions',
async (): Promise<{ id: string; cwd: string; title: string }[]> => {
const providerSessions = await Promise.all([
Promise.resolve({
connectionId: null as string | null,
sessions: await localProvider.listProcesses()
}),
...Array.from(sshProviders.entries(), async ([connectionId, provider]) => ({
connectionId,
sessions: await provider.listProcesses().catch(() => [])
}))
])
const deduped = new Map<string, { id: string; cwd: string; title: string }>()
for (const { connectionId, sessions } of providerSessions) {
for (const session of sessions) {
// Why: SessionsStatusSegment kill actions only send the PTY id back
// through IPC. Rebuild ownership while listing so remote sessions
// discovered after reconnect still route to their original provider.
ptyOwnership.set(session.id, connectionId)
deduped.set(session.id, session)
}
}
return Array.from(deduped.values())
}
)
ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
const provider = parsedSshId
? sshProviders.get(parsedSshId.connectionId)
: tryGetProviderForPty(args.id)
if (!provider?.hasPty) {
return null
}
try {
return provider.hasPty(args.id)
} catch {
// Why: liveness is only allowed to close panes on an authoritative false.
return null
}
})
ipcMain.handle(
'pty:hasChildProcesses',
async (_event, args: { id: string }): Promise<boolean> => {
if (!hasPtyProviderForInspection(args.id)) {
return false
}
return getProviderForPty(args.id).hasChildProcesses(args.id)
}
)
ipcMain.handle(
'pty:getForegroundProcess',
async (_event, args: { id: string }): Promise<string | null> => {
if (!hasPtyProviderForInspection(args.id)) {
return null
}
return getProviderForPty(args.id).getForegroundProcess(args.id)
}
)
// Why: renderer needs the live shell cwd when the user presses Cmd+D so
// the new split pane inherits the source pane's cwd instead of the
// worktree root. Routed through getProviderForPty so local and SSH PTYs
// use the same code path. Providers return '' when the id is unknown or
// the platform cannot resolve a cwd (Windows); the renderer treats ''
// as "fall through to the next fallback layer".
ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
try {
return await getProviderForPty(args.id).getCwd(args.id)
} catch {
return ''
}
})
// Why: the renderer forwards resizes fire-and-forget and otherwise has no way
// to learn the PTY's actual size. A resize dropped main-side (suppression
// window, mobile-driver gate, or a provider no-op) OR daemon/SSH-side (the
// remote resize notify is unacked and can be silently dropped — session not
// yet alive, exited, invalid dims, cold-restore snapshot-col coercion) leaves
// the renderer believing it synced when it did not, so a later same-cols
// layout never re-forwards and the TUI stays garbled. ptySizes records only
// the REQUESTED size, so it cannot reveal such a drop. Prefer the provider's
// APPLIED size (node-pty's cached winsize / the daemon emulator's dims, which
// track the subprocess resize) so the renderer's resume drift-check sees the
// truth; fall back to ptySizes only when the provider can't report (no
// getAppliedSize, e.g. SSH relay, or an unknown id) — a null then reads as
// "cannot confirm", which the renderer treats as a cue to re-forward once.
ipcMain.handle(
'pty:getSize',
async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => {
try {
const applied = await tryGetProviderForPty(args.id)?.getAppliedSize?.(args.id)
if (applied) {
return applied
}
} catch {
// Fall through to the requested-size cache on any provider/RPC failure
// so a dead daemon/relay never blocks or throws across the IPC boundary.
}
return ptySizes.get(args.id) ?? null
}
)
// Why: pre-signal handshake handlers. See
// docs/mobile-prefer-renderer-scrollback.md and the rationale on
// `pendingByPaneKey` above. The IPC contract is: renderer awaits declare
// (capturing the returned gen), awaits pty:spawn, then registers its
// serializer locally and calls settle (echoing the gen). On spawn rejection
// or pane unmount before settle, renderer calls clear with the same gen.
ipcMain.handle(
'pty:declarePendingPaneSerializer',
async (event, args: { paneKey?: unknown }): Promise<number> => {
if (!isValidPaneKey(args.paneKey)) {
throw new Error('Invalid paneKey')
}
return declarePendingPaneSerializer(args.paneKey, event?.sender)
}
)
ipcMain.handle(
'pty:settlePaneSerializer',
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
return
}
settlePendingPaneSerializer(args.paneKey, args.gen)
// Why: settle means the renderer has registered its serializer locally
// for whatever ptyId came back from spawn. The renderer doesn't carry
// the ptyId back through this IPC because the cooperation gate ran
// pre-spawn; instead we mark the pane as authoritative by paneKey →
// ptyId via the existing paneKeyPtyId mapping populated at spawn.
const ptyId = paneKeyPtyId.get(args.paneKey)
if (ptyId) {
rendererSerializerByPtyId.add(ptyId)
}
}
)
ipcMain.handle(
'pty:clearPendingPaneSerializer',
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
return
}
settlePendingPaneSerializer(args.paneKey, args.gen)
}
)
}
export function registerHeadlessPtyRuntime(
runtime: OrcaRuntimeService,
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store
): void {
// Why: headless `orca serve` has no renderer window, but the runtime still
// needs the same PTY controller and provider listeners as desktop so remote
// clients can create, stream, inspect, and stop terminals.
const headlessWindow = {
isDestroyed: () => true,
webContents: {
send: () => {},
on: () => {},
removeListener: () => {}
}
} as unknown as BrowserWindow
registerPtyHandlers(
headlessWindow,
runtime,
getSelectedCodexHomePath,
getSettings,
prepareClaudeAuth,
store
)
}
/**
* Kill all PTY processes. Call on app quit.
*/
export function killAllPty(): void {
if (localProvider instanceof LocalPtyProvider) {
localProvider.killAll()
}
}