Files
orca/src/relay/pty-handler.ts
T
NeilandOrca 79ec57d045 perf(relay): keep the PTY replay window as chunks, not a re-sliced string (#10900)
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string

appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)`
once over the 100KB cap. It runs per raw node-pty emission -- before batching --
so once a PTY saturates the window (which a long-lived shell does almost
immediately) every subsequent chunk copied the whole 100KB.

Reuse RecentPtyOutputBuffer, which already solved this shape in the main process:
keep chunks, drop from the head, defer the join to read(). The relay's three
readers are attach, adopt, and revive only.

66-205x on the append path, per PTY, on the user's SSH host.

RecentPtyOutputBuffer's limit is now configurable, because the relay retains
100KB where the main process retains 64KB. One arithmetic branch still used the
hardcoded constant after that change and silently under-retained (100,800 of
102,400 code units); the equivalence tests caught it before it shipped, and the
suite now pins the configured limit directly.

Co-authored-by: Orca <help@stably.ai>

* test(relay): exercise a real surrogate split; drop eval from the benchmark

Review feedback, both valid:

- The surrogate test never split a pair. The cap is even and a pair is two code
  units, so an emoji run alone always cuts on a pair boundary. A trailing single
  unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) --
  asserted directly now, with the boundary-aligned case kept as its own test.
- Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already
  admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval
  regardless of the eslint suppression.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:40:59 -07:00

1722 lines
62 KiB
TypeScript

/* oxlint-disable max-lines */
import type { IPty } from 'node-pty'
import type * as NodePty from 'node-pty'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
resolveDefaultShell,
resolveDefaultCwd,
resolveProcessCwd,
processHasChildren,
getForegroundProcessName,
isProcessAlive,
listShellProfiles
} from './pty-shell-utils'
import { getRelayShellLaunchConfig } from './pty-shell-launch'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../shared/startup-command-submission'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../shared/cross-platform-path'
import { splitWorktreeId } from '../shared/worktree-id'
import { PhysicalExitTracker } from '../shared/physical-exit-tracker'
import {
createShellReadyScanState,
drainShellReadyHeldBytes,
scanForShellReady,
type ShellReadyScanState
} from '../main/shell-ready-marker-scanner'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import {
gitCredentialPromptGuardEnv,
mergeGitConfigEnvProtocol
} from '../shared/git-credential-prompt-env'
import { isTuiAgent } from '../shared/tui-agent-config'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env'
import {
PTY_STARTUP_INGRESS_VERSION,
PtyStartupIngress,
parsePtyStartupIngressIntent,
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend'
import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer'
import {
agentSessionOwnerBindingsEqual,
ClaimedAgentPtyOwnerRegistry
} from '../shared/claimed-agent-pty-owner'
import {
AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION,
AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
isAgentSessionExecutionClaim,
isAgentSessionSurfaceBinding,
type AgentSessionOwnerBinding
} from '../shared/agent-session-host-authority'
// Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap
// there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never
// why — a skipped compile and a later Node/ABI flip look identical here — so Linux hedges both causes.
export function formatNodePtyUnavailableMessage(platform: NodeJS.Platform): string {
const remedy =
platform === 'linux'
? "node-pty's native binding is not loadable on this host. If it is missing the C/C++ build tools needed to compile node-pty, install make, a C++ compiler, and python3 on the remote host, then reconnect. Otherwise reconnect to reinstall the relay's native modules, and check that the remote Node.js version and architecture match the installed binding."
: "node-pty's native binding failed to load on this host. Reconnect to reinstall the relay's native modules; if it persists, check that the remote Node.js version and architecture match the installed binding."
return `Remote terminals are unavailable: ${remedy}`
}
function isMissingNodePtyNativeBinding(error: unknown): boolean {
return (
error instanceof Error &&
/Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message)
)
}
type ManagedPty = {
id: string
incarnationId: string
pty: IPty
initialCwd: string
/** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the
* whole window on every write once saturated. Readers are attach/adopt/revive only. */
buffered: RecentPtyOutputBuffer
/** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */
killTimer?: ReturnType<typeof setTimeout>
/** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */
disposed?: boolean
/** True once external cleanup observers have been notified. */
exitListenerNotified?: boolean
/** Renderer-supplied paneKey (ORCA_PANE_KEY); captured so exit observers can evict per-pane cache state. */
paneKey?: string
tabId?: string
/** Attach-only identity metadata (RPC). Separate from paneKey/tabId, which also drive shell env/revive hooks. */
attachIdentity?: PtyIdentity
worktreeId?: string
terminalHandle?: string
explicitTerm?: string
envToDelete: string[]
gitCredentialPromptGuarded: boolean
startupCommand?: ManagedStartupCommand
physicalExit?: PhysicalExitTracker
forceKillSent?: boolean
gracefulKillSent?: boolean
startupIngress?: PtyStartupIngress
startupIngressIntent?: ReturnType<typeof parsePtyStartupIngressIntent>
ownerBackend: PtyOwnerBackend
agentSessionOwners?: AgentSessionOwnerBinding[]
}
type RelayAgentSessionCreateResult = {
id: string
incarnationId: string
replay?: string
agentSessionEnsure?: unknown
}
const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/
const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000
const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096
type PendingPtyOutput = {
data: string
rawLength?: number
transformed?: boolean
seq?: number
}
type ManagedStartupCommand = {
command: string
delivered: boolean
waitForShellReady: boolean
scanState: ShellReadyScanState | null
timer: ReturnType<typeof setTimeout> | null
}
// Why: node-pty's Windows agent throws on any signal arg (ConPTY has no signal semantics); drop it there, forward on POSIX.
function killPtyProcess(pty: IPty, signal: string): void {
if (process.platform === 'win32') {
pty.kill()
return
}
if (signal === 'SIGKILL') {
forceKillPosixPtyProcessGroups(pty.pid, () => pty.kill(signal))
return
}
pty.kill(signal)
}
function finishPtyCreationOperations(operations: readonly (() => void)[]): void {
// Why: the relay still targets Node 18, which lacks Array.prototype.toReversed.
for (let index = operations.length - 1; index >= 0; index--) {
operations[index]()
}
}
function disposeManagedPty(managed: ManagedPty): void {
if (managed.disposed) {
return
}
managed.disposed = true
// Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance.
if (managed.killTimer) {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
// Why: neutralize pty.kill before destroy() so UnixTerminal's async 'close' SIGHUP can't hit a recycled pid.
// Windows exempt: its destroy() IS a kill() (via _deferNoArgs), so neutralizing leaks the ConPTY agent.
if (process.platform !== 'win32') {
;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {}
} else if (managed.gracefulKillSent || managed.forceKillSent) {
// Why: WindowsTerminal.destroy() calls kill(); a prior bare kill already closed ConPTY, so skip to avoid double-close.
return
}
try {
;(managed.pty as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow */
}
}
const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000
export const MAX_RELAY_PTY_SESSIONS = 50
export const REPLAY_BUFFER_MAX = 100 * 1024
const PTY_OUTPUT_BATCH_INTERVAL_MS = 8
const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1
const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024
const PTY_OUTPUT_FLUSH_MAX_WRITES = 2
const INTERACTIVE_OUTPUT_WINDOW_MS = 100
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
const STARTUP_COMMAND_WRITE_DELAY_MS = 50
const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
const PTY_FORCE_KILL_RETRY_DELAY_MS = 250
const PTY_FORCE_KILL_MAX_ATTEMPTS = 2
const ALLOWED_SIGNALS = new Set([
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGKILL',
'SIGTSTP',
'SIGCONT',
'SIGWINCH',
'SIGUSR1',
'SIGUSR2'
])
const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
'powershell.exe',
'powershell',
'pwsh.exe',
'pwsh',
'cmd.exe',
'cmd',
'wsl.exe',
'wsl',
WINDOWS_GIT_BASH_SHELL
])
function resolvePtyShellOverride(shellOverride: string): string {
if (!shellOverride) {
return ''
}
if (process.platform !== 'win32') {
return ''
}
const normalized = shellOverride.toLowerCase()
if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) {
throw new Error(`Unsupported Windows shell override: ${shellOverride}`)
}
return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
}
type PtyProcessSummary = {
id: string
incarnationId: string
cwd: string
title: string
worktreeId?: string
terminalHandle?: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}
type SerializedPtyEntry = {
id: string
pid: number
cols: number
rows: number
cwd: string
paneKey?: string
tabId?: string
attachIdentity?: PtyIdentity
worktreeId?: string
terminalHandle?: string
explicitTerm?: string
envToDelete?: string[]
/** Optional for state serialized by relays predating the credential guard. */
gitCredentialPromptGuarded?: boolean
agentSessionOwners?: AgentSessionOwnerBinding[]
}
function sanitizeEnvToDelete(value: unknown): string[] {
return Array.isArray(value)
? value
.filter((key): key is string => typeof key === 'string' && key.length > 0)
.slice(0, 1_024)
: []
}
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
type PtyIdentity = { paneKey?: string; tabId?: string }
/**
* True when a reattach's expected pane identity contradicts the target PTY's own.
* Rejects cross-relay-generation id collisions (a reset relay reuses `pty-N`).
* Only compares fields present on both sides; absent identity stays permissive.
*/
export function attachIdentityMismatches(expected: PtyIdentity, managed: PtyIdentity): boolean {
return Boolean(
(expected.paneKey && managed.paneKey && expected.paneKey !== managed.paneKey) ||
(expected.tabId && managed.tabId && expected.tabId !== managed.tabId)
)
}
/** Returns env to merge into the PTY's spawn env. Receives spawn context so augmenters can derive per-PTY identity from paneKey.
* `command` is the renderer-chosen agent launch command (`pi`, `omp`, …); undefined for CLI-launched bare shells. */
export type PtyEnvAugmenter = (ctx: {
id: string
paneKey?: string
shell: string
env: Record<string, string>
command?: string
}) => Record<string, string>
export type RelayPtyWorktreeRemovalCoordinator = {
beginWorktreePtySpawn(operationPath: string): () => void
}
export class PtyHandler {
private ptys = new Map<string, ManagedPty>()
private nextId = 1
private dispatcher: RelayDispatcher
private graceTimeMs: number
private graceTimer: ReturnType<typeof setTimeout> | null = null
private outputFlushTimer: ReturnType<typeof setTimeout> | null = null
private pendingOutputByPty = new Map<string, PendingPtyOutput>()
private lastInputAtByPty = new Map<string, number>()
private interactiveOutputCharsByPty = new Map<string, number>()
private pendingSpawnCount = 0
private pendingReviveIds = new Set<string>()
private creationFenced = false
private pendingCreationDrainResolvers = new Set<() => void>()
private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null
private disposePromise: Promise<void> | null = null
private ptyModule: typeof NodePty | null = null
private ptyModuleLoadPromise: Promise<typeof NodePty | null> | null = null
private reloadPtyModuleFromDisk = false
// Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup.
private exitListener: PtyExitListener | null = null
// Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks.
private envAugmenters: PtyEnvAugmenter[] = []
private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
private readonly agentSessionCreateOperations = new Map<
string,
Promise<RelayAgentSessionCreateResult>
>()
constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) {
this.dispatcher = dispatcher
this.graceTimeMs = graceTimeMs
this.registerHandlers()
}
private async loadPty(): Promise<typeof NodePty | null> {
if (this.ptyModule) {
return this.ptyModule
}
if (this.ptyModuleLoadPromise) {
return this.ptyModuleLoadPromise
}
this.ptyModuleLoadPromise = this.loadPtyUncached()
try {
return await this.ptyModuleLoadPromise
} finally {
this.ptyModuleLoadPromise = null
}
}
private async loadPtyUncached(): Promise<typeof NodePty | null> {
if (!this.reloadPtyModuleFromDisk) {
try {
this.ptyModule = await import('node-pty')
return this.ptyModule
} catch {
this.reloadPtyModuleFromDisk = true
}
}
// Why: tie module resolution to the deployed bundle dir, not cwd.
const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js')
if (!existsSync(moduleEntry)) {
return null
}
try {
this.ptyModule = require(moduleEntry) as typeof NodePty
return this.ptyModule
} catch {
return null
}
}
private invalidatePtyModuleAfterBindingFailure(): void {
this.ptyModule = null
this.reloadPtyModuleFromDisk = true
const moduleRoot = join(__dirname, 'node_modules', 'node-pty')
for (const cachedPath of Object.keys(require.cache)) {
if (isPathInsideOrEqual(moduleRoot, cachedPath)) {
delete require.cache[cachedPath]
}
}
}
setGraceTimeMs(graceTimeMs: number): void {
this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs))
}
setWorktreeRemovalCoordinator(coordinator: RelayPtyWorktreeRemovalCoordinator | null): void {
this.worktreeRemovalCoordinator = coordinator
}
async shutdownForWorktreePath(rootPath: string): Promise<void> {
const matchingIds = [...this.ptys.values()]
.filter((managed) => {
const ownedPath = managed.worktreeId
? splitWorktreeId(managed.worktreeId)?.worktreePath
: undefined
return (
(ownedPath !== undefined && isPathInsideOrEqual(rootPath, ownedPath)) ||
isPathInsideOrEqual(rootPath, managed.initialCwd)
)
})
.map((managed) => managed.id)
await Promise.all(matchingIds.map((id) => this.shutdown({ id, immediate: true })))
}
get configuredGraceTimeMs(): number {
return this.graceTimeMs
}
/** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */
setExitListener(listener: PtyExitListener | null): void {
this.exitListener = listener
}
/** Register an env augmenter merged into every spawn env *after* process.env and renderer env.
* Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords. See docs/design/agent-status-over-ssh.md §3. */
addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void {
this.envAugmenters.push(augmenter)
return () => {
const idx = this.envAugmenters.indexOf(augmenter)
if (idx !== -1) {
this.envAugmenters.splice(idx, 1)
}
}
}
/** Build augmented spawn env; augmenter values win over process.env/renderer env. Shared by spawn()/revive() so precedence can't drift. */
private buildSpawnEnv(
rendererEnv: Record<string, string> | undefined,
ctx: { id: string; paneKey?: string; shell: string; command?: string },
envToDelete: readonly string[] = []
): Record<string, string> {
const baseEnv = mergeGitConfigEnvProtocol(
{
...stripInheritedBuildModeEnv(process.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION:
rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev',
FORCE_HYPERLINK: '1'
},
rendererEnv
) as Record<string, string>
const augmented: Record<string, string> = {}
for (const augmenter of this.envAugmenters) {
try {
Object.assign(augmented, augmenter({ ...ctx, env: baseEnv }))
} catch (err) {
process.stderr.write(
`[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record<string, string>
// Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values.
for (const key of envToDelete) {
delete result[key]
}
if (
!envToDelete.includes('TERM') &&
rendererEnv &&
Object.prototype.hasOwnProperty.call(rendererEnv, 'TERM')
) {
result.TERM = rendererEnv.TERM
}
// Why: node-pty defaults missing/empty TERM per-platform; normalize so POSIX and Windows children agree.
if (!result.TERM) {
result.TERM = 'xterm-256color'
}
return result
}
private clearStartupCommandTimer(managed: ManagedPty): void {
if (managed.startupCommand?.timer) {
clearTimeout(managed.startupCommand.timer)
managed.startupCommand.timer = null
}
}
private appendReplayBuffer(managed: ManagedPty, data: string): void {
if (data.length === 0) {
return
}
managed.buffered.append(data)
}
private releaseStartupCommand(managed: ManagedPty): void {
this.clearStartupCommandTimer(managed)
managed.startupCommand = undefined
}
private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
return
}
this.clearStartupCommandTimer(managed)
startup.timer = setTimeout(() => {
startup.timer = null
this.deliverStartupCommand(managed)
}, delayMs)
}
private deliverStartupCommand(managed: ManagedPty): void {
const startup = managed.startupCommand
if (!startup || startup.delivered || managed.disposed) {
return
}
startup.delivered = true
this.clearStartupCommandTimer(managed)
if (startup.scanState) {
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
if (heldBytes) {
managed.startupIngress?.accept(heldBytes)
}
}
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed.
const payload = buildStartupCommandSubmission(startup.command, {
submit,
bracketedPasteSafe: startup.waitForShellReady
})
managed.startupCommand = undefined
managed.pty.write(payload)
}
/** Wire onData/onExit listeners for a managed PTY and store it. */
private wireAndStore(managed: ManagedPty): void {
managed.physicalExit = new PhysicalExitTracker()
this.ptys.set(managed.id, managed)
const emitIngressData = (emission: PtyIngressEmission): void => {
const rawLength = emission.rawEndSeq - emission.rawStartSeq
this.appendReplayBuffer(managed, emission.data)
this.enqueuePtyOutput(
managed.id,
emission.data,
emission.transformed || rawLength !== emission.data.length
? { rawLength, seq: emission.rawEndSeq, transformed: true }
: {}
)
}
managed.startupIngress ??= new PtyStartupIngress({
...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}),
ownerBackend: managed.ownerBackend,
write: (data) => managed.pty.write(data),
onEmission: emitIngressData
})
managed.pty.onData((data: string) => {
const startup = managed.startupCommand
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
const scanned = scanForShellReady(startup.scanState, data)
data = scanned.output
if (scanned.matched) {
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
}
}
managed.startupIngress?.accept(data)
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
managed.physicalExit?.markExited()
if (managed.disposed) {
return
}
// Why: neutralize pty.kill synchronously so node-pty's 'close' SIGHUP can't hit a recycled pid on POSIX.
if (process.platform !== 'win32') {
;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {}
}
// Why: clear the SIGKILL fallback timer on clean exit so it doesn't fire later.
if (managed.killTimer) {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
this.releaseRelayIngress(managed)
this.flushPtyOutput(managed.id)
this.dispatcher.notify('pty.exit', {
id: managed.id,
code: exitCode,
incarnationId: managed.incarnationId
})
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
this.ptys.delete(managed.id)
this.clearPtyFlowState(managed.id)
// Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md).
disposeManagedPty(managed)
})
}
private releaseRelayIngress(managed: ManagedPty): void {
const startupCommand = managed.startupCommand
const scanState = startupCommand?.scanState
if (scanState) {
const held = drainShellReadyHeldBytes(scanState)
startupCommand.scanState = null
managed.startupIngress?.accept(held)
}
managed.startupIngress?.drainAndClose()
}
private notifyExitListener(managed: ManagedPty): void {
if (managed.exitListenerNotified) {
return
}
managed.exitListenerNotified = true
// Why: notify exactly once — both physical exit and whole-relay disposal reach here.
if (this.exitListener) {
try {
this.exitListener({ id: managed.id, paneKey: managed.paneKey })
} catch (err) {
process.stderr.write(
`[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
}
private registerHandlers(): void {
this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context))
this.dispatcher.onRequest('pty.attach', (p) => this.attach(p))
this.dispatcher.onRequest('pty.shutdown', (p) => this.shutdown(p))
this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p))
this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p))
this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p))
this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p))
this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p))
this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p))
this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p))
this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p))
this.dispatcher.onRequest('pty.getCapabilities', async () => ({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
}))
this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses())
this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell())
this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p))
this.dispatcher.onRequest('pty.revive', (p) => this.revive(p))
this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles())
this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) =>
this.closeStartupQueryAuthority(p)
)
this.dispatcher.onNotification('pty.data', (p) => this.writeData(p))
this.dispatcher.onNotification('pty.resize', (p) => this.resize(p))
this.dispatcher.onNotification('pty.ackData', (_p) => {
/* flow control ack -- not yet enforced */
})
}
private isLikelyInteractiveRedraw(data: string): boolean {
if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) {
return true
}
return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[')
}
private async closeStartupQueryAuthority(
params: Record<string, unknown>
): Promise<{ appliedSeq: number }> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
return { appliedSeq: managed.startupIngress?.closeQueryAuthority() ?? 0 }
}
private shouldSendInteractiveOutputNow(id: string, data: string): boolean {
const lastInputAt = this.lastInputAtByPty.get(id)
const now = performance.now()
if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) {
this.interactiveOutputCharsByPty.delete(id)
return false
}
if (!this.isLikelyInteractiveRedraw(data)) {
this.interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS)
return false
}
const usedChars = this.interactiveOutputCharsByPty.get(id) ?? 0
if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) {
this.interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS)
return false
}
this.interactiveOutputCharsByPty.set(id, usedChars + data.length)
return true
}
private enqueuePtyOutput(
id: string,
data: string,
meta: { rawLength?: number; transformed?: boolean; seq?: number } = {}
): void {
const existing = this.pendingOutputByPty.get(id)
if (meta.transformed === true) {
// Why: transformed spans lack a raw-to-clean slice mapping, so they can't be folded into the output batch.
if (existing) {
this.flushPtyOutput(id)
}
this.dispatcher.notify('pty.data', { id, data, ...meta })
return
}
const pending: PendingPtyOutput = { data: (existing?.data ?? '') + data }
if (existing?.rawLength !== undefined || meta.rawLength !== undefined) {
pending.rawLength =
(existing?.rawLength ?? existing?.data.length ?? 0) + (meta.rawLength ?? data.length)
}
if (meta.seq !== undefined) {
pending.seq = meta.seq
}
if (this.shouldSendInteractiveOutputNow(id, pending.data)) {
this.pendingOutputByPty.delete(id)
this.clearOutputFlushTimerIfIdle()
// Why: send interactive echo immediately — batching must not add visible input delay for TUIs.
this.dispatcher.notify('pty.data', { id, ...pending })
return
}
this.pendingOutputByPty.set(id, pending)
this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS)
}
private scheduleOutputFlush(delayMs: number): void {
if (this.outputFlushTimer !== null) {
return
}
this.outputFlushTimer = setTimeout(() => this.flushPendingOutput(), delayMs)
}
private flushPendingOutput(): void {
this.outputFlushTimer = null
// Why batch before the first send: a re-entrant sink must read the values a whole-map snapshot
// would have frozen. Why the raw iterator: `for...of` would consume one entry past the limit.
const pendingEntries = this.pendingOutputByPty[Symbol.iterator]()
const batch: [string, PendingPtyOutput][] = []
while (batch.length < PTY_OUTPUT_FLUSH_MAX_WRITES) {
const next = pendingEntries.next()
if (next.done === true) {
break
}
batch.push(next.value)
}
for (const [id, pending] of batch) {
this.pendingOutputByPty.delete(id)
const chunk = pending.transformed
? pending.data
: pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS)
const remaining = pending.transformed ? '' : pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS)
if (remaining) {
this.pendingOutputByPty.set(id, {
data: remaining,
...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }),
seq: pending.seq
})
}
const chunkRawLength = pending.transformed
? pending.rawLength
: pending.rawLength === undefined
? undefined
: chunk.length
const chunkSeq =
pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length)
this.dispatcher.notify('pty.data', {
id,
data: chunk,
...(chunkSeq === undefined ? {} : { seq: chunkSeq }),
...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }),
...(pending.transformed ? { transformed: true } : {})
})
}
if (this.pendingOutputByPty.size > 0 && batch.length > 0) {
// Why: yield between slices of a large chunk so client input and control frames can interleave.
this.scheduleOutputFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS)
}
}
private flushPtyOutput(id: string): void {
const pending = this.pendingOutputByPty.get(id)
if (!pending) {
return
}
this.pendingOutputByPty.delete(id)
this.dispatcher.notify('pty.data', { id, ...pending })
this.clearOutputFlushTimerIfIdle()
}
private clearOutputFlushTimerIfIdle(): void {
if (this.pendingOutputByPty.size > 0 || this.outputFlushTimer === null) {
return
}
clearTimeout(this.outputFlushTimer)
this.outputFlushTimer = null
}
private clearPtyFlowState(id: string): void {
this.pendingOutputByPty.delete(id)
this.lastInputAtByPty.delete(id)
this.interactiveOutputCharsByPty.delete(id)
this.clearOutputFlushTimerIfIdle()
}
private beginPtyCreation(operationPaths: readonly (string | undefined)[]): () => void {
if (this.creationFenced) {
throw new Error('PTY handler is shutting down')
}
const distinctPaths = new Map<string, string>()
for (const operationPath of operationPaths) {
if (operationPath) {
distinctPaths.set(normalizeRuntimePathForComparison(operationPath), operationPath)
}
}
const finishRemovalOperations: (() => void)[] = []
try {
if (this.worktreeRemovalCoordinator) {
for (const operationPath of distinctPaths.values()) {
finishRemovalOperations.push(
this.worktreeRemovalCoordinator.beginWorktreePtySpawn(operationPath)
)
}
}
if (this.ptys.size + this.pendingSpawnCount >= MAX_RELAY_PTY_SESSIONS) {
throw new Error('Maximum number of PTY sessions reached (50)')
}
} catch (error) {
// Why: a later rejection must release every earlier admission before propagating.
finishPtyCreationOperations(finishRemovalOperations)
throw error
}
this.pendingSpawnCount++
let finished = false
return () => {
if (finished) {
return
}
finished = true
this.pendingSpawnCount--
if (this.pendingSpawnCount === 0) {
for (const resolve of this.pendingCreationDrainResolvers) {
resolve()
}
this.pendingCreationDrainResolvers.clear()
}
finishPtyCreationOperations(finishRemovalOperations)
}
}
private waitForPendingPtyCreations(): Promise<void> {
if (this.pendingSpawnCount === 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pendingCreationDrainResolvers.add(resolve)
})
}
private async spawn(
params: Record<string, unknown>,
context?: RequestContext
): Promise<RelayAgentSessionCreateResult> {
const operationId = params.agentSessionCreateOperationId
if (operationId === undefined) {
return await this.spawnOnce(params, context)
}
if (
typeof operationId !== 'string' ||
!AGENT_SESSION_CREATE_OPERATION_ID_PATTERN.test(operationId)
) {
throw new Error('agent_session_operation_invalid')
}
const existing = this.agentSessionCreateOperations.get(operationId)
if (existing) {
return await existing
}
if (this.agentSessionCreateOperations.size >= AGENT_SESSION_CREATE_OPERATION_LIMIT) {
throw new Error('agent_session_operation_capacity')
}
const operation = this.spawnOnce(params, context)
this.agentSessionCreateOperations.set(operationId, operation)
try {
const result = await operation
this.expireAgentSessionCreateOperation(operationId, operation)
return result
} catch (error) {
const outcomeUnknown =
typeof error === 'object' &&
error !== null &&
'agentSessionOperationOutcome' in error &&
error.agentSessionOperationOutcome === 'unknown'
if (outcomeUnknown) {
// Why: the native PTY may be live; replay the same failure instead of spawning again.
this.expireAgentSessionCreateOperation(operationId, operation)
} else if (this.agentSessionCreateOperations.get(operationId) === operation) {
this.agentSessionCreateOperations.delete(operationId)
}
throw error
}
}
private expireAgentSessionCreateOperation(
operationId: string,
operation: Promise<RelayAgentSessionCreateResult>
): void {
const timer = setTimeout(() => {
if (this.agentSessionCreateOperations.get(operationId) === operation) {
this.agentSessionCreateOperations.delete(operationId)
}
}, AGENT_SESSION_CREATE_OPERATION_RETENTION_MS)
timer.unref?.()
}
private async spawnOnce(
params: Record<string, unknown>,
context?: RequestContext
): Promise<RelayAgentSessionCreateResult> {
const env = params.env as Record<string, string> | undefined
const worktreeId = env?.ORCA_WORKTREE_ID
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd()
const finishCreation = this.beginPtyCreation([worktreePath, cwd])
let physicalSpawnCommitted = false
const markPhysicalSpawnCommitted = (): void => {
physicalSpawnCommitted = true
}
try {
const ensure = params.agentSessionEnsure as { claim?: unknown; surface?: unknown } | undefined
if (!ensure) {
return await this.spawnAfterAdmission(params, context, markPhysicalSpawnCommitted)
}
if (
!isAgentSessionExecutionClaim(ensure.claim) ||
!isAgentSessionSurfaceBinding(ensure.surface)
) {
throw new Error('agent_session_identity_required')
}
const claim = ensure.claim
const surface = ensure.surface
const result = await this.agentSessionOwners.ensure({
claim,
surface,
spawn: async ({ generation }) => {
const created = await this.spawnAfterAdmission(
params,
context,
markPhysicalSpawnCommitted
)
const managed = this.ptys.get(created.id)
if (managed) {
managed.agentSessionOwners = [
{
claim,
generation,
phase: 'live',
ptyId: created.id,
surface
}
]
}
return { ptyId: created.id }
},
isLive: (owner) => {
const managed = this.ptys.get(owner.ptyId)
return Boolean(
managed &&
!managed.disposed &&
(!managed.pty.pid || isProcessAlive(managed.pty.pid)) &&
managed.agentSessionOwners?.some((candidate) =>
agentSessionOwnerBindingsEqual(candidate, owner)
)
)
}
})
const managed = this.ptys.get(result.owner.ptyId)
if (!managed || managed.disposed) {
this.agentSessionOwners.release(result.owner.ptyId, result.owner.generation)
throw new Error('agent_session_exited_during_start')
}
managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id)
const adoptedReplay = result.disposition === 'adopted' ? managed.buffered.read() : ''
return {
id: managed.id,
incarnationId: managed.incarnationId,
agentSessionEnsure: result,
...(adoptedReplay ? { replay: adoptedReplay } : {})
}
} catch (error) {
if (!physicalSpawnCommitted) {
throw error
}
const message = error instanceof Error ? error.message : String(error)
throw Object.assign(new Error(message), {
agentSessionOperationOutcome: 'unknown' as const
})
} finally {
finishCreation()
}
}
private async spawnAfterAdmission(
params: Record<string, unknown>,
context?: RequestContext,
onPhysicalSpawnCommitted?: () => void
): Promise<{ id: string; incarnationId: string }> {
const pty = await this.loadPty()
if (!pty) {
throw new Error(formatNodePtyUnavailableMessage(process.platform))
}
const cols = (params.cols as number) || 80
const rows = (params.rows as number) || 24
const cwd = (params.cwd as string) || resolveDefaultCwd()
const env = params.env as Record<string, string> | undefined
const envToDelete = sanitizeEnvToDelete(params.envToDelete)
const explicitTerm =
!envToDelete.includes('TERM') &&
env &&
Object.prototype.hasOwnProperty.call(env, 'TERM') &&
typeof env.TERM === 'string' &&
env.TERM.length > 0
? env.TERM
: undefined
const shellOverride =
typeof params.shellOverride === 'string' ? params.shellOverride.trim() : ''
const resolvedShellOverride = resolvePtyShellOverride(shellOverride)
const shell = resolvedShellOverride || resolveDefaultShell()
let id: string
do {
id = `pty-${this.nextId++}`
} while (this.ptys.has(id) || this.pendingReviveIds.has(id))
// Why: augmenter values override renderer env so remote paths and hook coords win over local userData.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
// Why: kept so a restarted runtime can re-adopt this PTY under its original handle (survives revive).
const terminalHandle =
typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined
const command = typeof params.command === 'string' ? params.command : undefined
const terminalWindowsWslDistro =
typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null
const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
// Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env.
const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, {
launchCommand: launchCommandHint,
isUnattended: isTuiAgent(params.launchAgent),
platform: process.platform
})
const shouldEmitShellReadyMarker =
launchCommandHint !== undefined &&
shouldUseShellReadyStartupDelivery({
command: launchCommandHint,
startupCommandDelivery:
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
})
// Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output.
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
terminalWindowsWslDistro,
emitReadyMarker: shouldEmitShellReadyMarker
})
if (context?.signal?.aborted || context?.isStale()) {
// Why: cancellation remains side-effect-free until the exact native spawn seam.
throw new Error('client_disconnected')
}
// Why: SSH exec channels give the relay a minimal environment without
// .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
// When overlays are injected, the launch wrapper keeps those paths after
// user startup files re-export their defaults.
let term: IPty
try {
term = pty.spawn(shell, shellLaunch.args, {
// Why: node-pty overwrites env.TERM with `name`; pass caller-selected TERM so it isn't lost.
name: spawnEnv.TERM ?? 'xterm-256color',
cols,
rows,
cwd,
// Why: relay shells inherit process.env; don't let an ambient Orca marker enable shell-ready unless requested.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
} catch (error) {
// Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here.
if (isMissingNodePtyNativeBinding(error)) {
this.invalidatePtyModuleAfterBindingFailure()
throw new Error(formatNodePtyUnavailableMessage(process.platform))
}
throw error
}
onPhysicalSpawnCommitted?.()
// Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map.
const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined
const attachIdentity = {
paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey,
tabId: typeof params.tabId === 'string' ? params.tabId : tabId
}
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
const startupIngressIntent =
params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION
? parsePtyStartupIngressIntent(params.startupIngress)
: undefined
const managed: ManagedPty = {
id,
incarnationId: randomUUID(),
pty: term,
initialCwd: cwd,
buffered: new RecentPtyOutputBuffer({
preserveChunkBoundaries: false,
limit: REPLAY_BUFFER_MAX
}),
paneKey,
tabId,
...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}),
worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: shell,
wslDistro: terminalWindowsWslDistro
}),
...(startupIngressIntent ? { startupIngressIntent } : {}),
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
? {
startupCommand: {
command,
delivered: false,
waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
scanState:
shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
? createShellReadyScanState()
: null,
timer: null
}
}
: {})
}
this.wireAndStore(managed)
if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) {
// Why: if the client reconnected while pty.spawn was in flight, the
// response is discarded and no renderer can own this PTY. Shut it down
// immediately so it does not linger as an unreachable remote shell.
this.releaseStartupCommand(managed)
this.requestGracefulKill(managed, 'terminate stale')
} else if (managed.startupCommand) {
this.scheduleStartupCommandDelivery(
managed,
managed.startupCommand.waitForShellReady
? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
: STARTUP_COMMAND_WRITE_DELAY_MS
)
}
return { id, incarnationId: managed.incarnationId }
}
private async attach(
params: Record<string, unknown>
): Promise<{ incarnationId: string; replay?: string }> {
const id = params.id as string
const managed = this.ptys.get(id)
// Why: after dispose, pty.kill is a POSIX no-op; treat disposed as not-found so failures aren't silent.
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
// Why: a shell can die without node-pty firing onExit (reaped out-of-band); prove liveness so attach doesn't strand a dead, lingering lease.
if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
managed.physicalExit?.markExited()
this.releaseRelayIngress(managed)
this.flushPtyOutput(id)
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(id)
this.clearPtyFlowState(id)
throw new Error(`PTY "${id}" not found`)
}
// Why: a relay generation reset can reuse pty-N for a different pane; reject on identity disagreement (absent identity permissive).
const mismatch = attachIdentityMismatches(
{
paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined,
tabId: typeof params.expectedTabId === 'string' ? params.expectedTabId : undefined
},
managed.attachIdentity ?? { paneKey: managed.paneKey, tabId: managed.tabId }
)
if (mismatch) {
throw new Error(`PTY "${id}" not found (identity mismatch)`)
}
managed.startupIngress?.snapshotBarrier()
// Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early.
// Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history.
const replay = managed.buffered.read()
if (replay) {
// Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice.
this.pendingOutputByPty.delete(id)
this.clearOutputFlushTimerIfIdle()
if (params.suppressReplayNotification) {
return { incarnationId: managed.incarnationId, replay }
}
this.dispatcher.notify('pty.replay', { id, data: replay })
}
return { incarnationId: managed.incarnationId }
}
private writeData(params: Record<string, unknown>): void {
const id = params.id as string
const data = params.data as string
if (typeof data !== 'string') {
return
}
const managed = this.ptys.get(id)
if (managed && !managed.disposed) {
this.lastInputAtByPty.set(id, performance.now())
this.interactiveOutputCharsByPty.set(id, 0)
managed.pty.write(data)
}
}
private resize(params: Record<string, unknown>): void {
const id = params.id as string
const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80)))
const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24)))
const managed = this.ptys.get(id)
if (managed && !managed.disposed) {
managed.pty.resize(cols, rows)
}
}
private async getSize(
params: Record<string, unknown>
): Promise<{ cols: number; rows: number } | null> {
const managed = this.ptys.get(params.id as string)
if (!managed || managed.disposed) {
return null
}
return { cols: managed.pty.cols, rows: managed.pty.rows }
}
private async shutdown(params: Record<string, unknown>): Promise<void> {
const id = params.id as string
const immediate = params.immediate as boolean
const managed = this.ptys.get(id)
if (!managed) {
return
}
if (immediate) {
this.releaseStartupCommand(managed)
this.flushPtyOutput(id)
this.requestForceKill(managed)
// Why: remote Git deletion must not race the child's native handles; on timeout keep the map entry so onExit/retry still owns it.
await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS)
} else {
this.releaseStartupCommand(managed)
this.requestGracefulKill(managed, 'force-kill')
}
}
private async sendSignal(params: Record<string, unknown>): Promise<void> {
const id = params.id as string
const signal = params.signal as string
if (!ALLOWED_SIGNALS.has(signal)) {
throw new Error(`Signal not allowed: ${signal}`)
}
const managed = this.ptys.get(id)
// Why: dispose neutralizes pty.kill on POSIX; treat disposed as not-found so signals don't silently no-op.
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
managed.pty.kill(signal)
}
private waitForPhysicalExit(managed: ManagedPty, timeoutMs: number): Promise<void> {
const physicalExit = managed.physicalExit
if (!physicalExit) {
return Promise.reject(new Error(`PTY "${managed.id}" exit tracking unavailable`))
}
return physicalExit.waitForExit(
timeoutMs,
() => new Error(`Timed out waiting for PTY process exit: ${managed.id}`)
)
}
private requestGracefulKill(
managed: ManagedPty,
fallbackAction: 'terminate stale' | 'force-kill'
): void {
if (managed.gracefulKillSent) {
return
}
managed.gracefulKillSent = true
if (process.platform === 'win32') {
// Why: ConPTY's bare kill is already force-final; block any later close of the handle.
managed.forceKillSent = true
}
try {
killPtyProcess(managed.pty, 'SIGTERM')
} catch (error) {
managed.gracefulKillSent = false
managed.forceKillSent = false
throw error
}
if (process.platform === 'win32') {
return
}
// Why: POSIX children may ignore SIGTERM; arm a bounded SIGKILL fallback.
this.armForceKillFallback(managed, fallbackAction, 5000, PTY_FORCE_KILL_MAX_ATTEMPTS)
}
private armForceKillFallback(
managed: ManagedPty,
fallbackAction: 'terminate stale' | 'force-kill',
delayMs: number,
attemptsRemaining: number
): void {
managed.killTimer = setTimeout(() => {
managed.killTimer = undefined
const still = this.ptys.get(managed.id)
if (!still || still.disposed) {
return
}
try {
this.requestForceKill(still)
} catch (error) {
process.stderr.write(
`[pty-handler] failed to ${fallbackAction} PTY ${managed.id}: ${error instanceof Error ? error.message : String(error)}\n`
)
// Why: a transient SIGKILL failure must not strand an unreachable remote shell.
if (attemptsRemaining > 1 && this.ptys.get(still.id) === still && !still.disposed) {
this.armForceKillFallback(
still,
fallbackAction,
PTY_FORCE_KILL_RETRY_DELAY_MS,
attemptsRemaining - 1
)
}
}
}, delayMs)
}
private requestForceKill(managed: ManagedPty): void {
if (managed.forceKillSent || (process.platform === 'win32' && managed.gracefulKillSent)) {
return
}
managed.forceKillSent = true
try {
killPtyProcess(managed.pty, 'SIGKILL')
} catch (error) {
managed.forceKillSent = false
throw error
}
}
private async getCwd(params: Record<string, unknown>): Promise<string> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
return resolveProcessCwd(managed.pty.pid, managed.initialCwd)
}
private async getInitialCwd(params: Record<string, unknown>): Promise<string> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
return managed.initialCwd
}
private async clearBuffer(params: Record<string, unknown>): Promise<void> {
const id = params.id as string
const managed = this.ptys.get(id)
if (managed && !managed.disposed) {
managed.startupIngress?.snapshotBarrier()
managed.pty.clear()
}
}
private async hasChildProcesses(params: Record<string, unknown>): Promise<boolean> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
return false
}
return await processHasChildren(managed.pty.pid)
}
private async getForegroundProcess(params: Record<string, unknown>): Promise<string | null> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
return null
}
return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)
}
private async inspectProcess(params: Record<string, unknown>): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
}> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error('terminal_gone')
}
const foregroundProcess = await getForegroundProcessName(
managed.pty.pid,
managed.pty.process || null
)
return {
foregroundProcess,
hasChildProcesses: await processHasChildren(managed.pty.pid)
}
}
private async listProcesses(): Promise<PtyProcessSummary[]> {
const results: PtyProcessSummary[] = []
for (const [id, managed] of this.ptys) {
const title =
(await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell'
results.push({
id,
incarnationId: managed.incarnationId,
cwd: managed.initialCwd,
title,
...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}),
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}),
...(this.agentSessionOwners.listForPty(id).length
? { agentSessionOwners: this.agentSessionOwners.listForPty(id) }
: {})
})
}
return results
}
private async serialize(params: Record<string, unknown>): Promise<string> {
const ids = params.ids as string[]
const entries: SerializedPtyEntry[] = []
for (const id of ids) {
const managed = this.ptys.get(id)
if (!managed) {
continue
}
const { pid, cols, rows } = managed.pty
entries.push({
id,
pid,
cols,
rows,
cwd: managed.initialCwd,
paneKey: managed.paneKey,
tabId: managed.tabId,
attachIdentity: managed.attachIdentity,
worktreeId: managed.worktreeId,
...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}),
envToDelete: managed.envToDelete,
gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded,
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
})
}
return JSON.stringify(entries)
}
private async revive(params: Record<string, unknown>): Promise<void> {
const state = params.state as string
const entries = JSON.parse(state) as SerializedPtyEntry[]
for (const entry of entries) {
if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) {
continue
}
// Only re-attach if the original process is still alive
try {
process.kill(entry.pid, 0)
} catch {
continue
}
const ownedPath = entry.worktreeId
? splitWorktreeId(entry.worktreeId)?.worktreePath
: undefined
const finishCreation = this.beginPtyCreation([ownedPath, entry.cwd])
this.pendingReviveIds.add(entry.id)
try {
await this.reviveEntry(entry)
} finally {
this.pendingReviveIds.delete(entry.id)
finishCreation()
}
}
}
private async reviveEntry(entry: SerializedPtyEntry): Promise<void> {
const ptyMod = await this.loadPty()
if (!ptyMod) {
return
}
// Why: pane identity comes from the serialized entry (not env) since hook scripts exit without ORCA_PANE_KEY.
const revivedEnv: Record<string, string> = {}
if (entry.paneKey) {
revivedEnv.ORCA_PANE_KEY = entry.paneKey
}
if (entry.tabId) {
revivedEnv.ORCA_TAB_ID = entry.tabId
}
if (entry.worktreeId) {
revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId
}
if (entry.terminalHandle) {
revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle
}
const explicitTerm =
typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0
? entry.explicitTerm
: undefined
if (explicitTerm !== undefined) {
revivedEnv.TERM = explicitTerm
}
// Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds.
const envToDelete = sanitizeEnvToDelete(entry.envToDelete)
const shell = resolveDefaultShell()
const spawnEnv = this.buildSpawnEnv(
revivedEnv,
{ id: entry.id, paneKey: entry.paneKey, shell },
envToDelete
)
// Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded).
const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true
if (gitCredentialPromptGuarded) {
Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform))
}
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
const term = ptyMod.spawn(shell, shellLaunch.args, {
name: spawnEnv.TERM ?? 'xterm-256color',
cols: entry.cols,
rows: entry.rows,
cwd: entry.cwd,
// Why: no provider-delivered command is waiting for a ready marker.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
this.wireAndStore({
id: entry.id,
incarnationId: randomUUID(),
pty: term,
initialCwd: entry.cwd,
buffered: new RecentPtyOutputBuffer({
preserveChunkBoundaries: false,
limit: REPLAY_BUFFER_MAX
}),
paneKey: entry.paneKey,
tabId: entry.tabId,
attachIdentity: entry.attachIdentity,
worktreeId: entry.worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: shell
}),
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {})
})
const match = entry.id.match(/^pty-(\d+)$/)
if (match) {
this.nextId = Math.max(this.nextId, Number.parseInt(match[1], 10) + 1)
}
}
startGraceTimer(onExpire: () => void, timeoutMs = this.graceTimeMs): void {
this.cancelGraceTimer()
if (timeoutMs === 0) {
return
}
// Why: connected relays keep the configured grace so live PTYs survive restarts/reconnects.
this.graceTimer = setTimeout(() => {
onExpire()
}, timeoutMs)
}
cancelGraceTimer(): void {
if (this.graceTimer) {
clearTimeout(this.graceTimer)
this.graceTimer = null
}
}
dispose(options: { waitForPhysicalExit?: boolean } = {}): Promise<void> {
// Why: fence synchronously before the first await so a spawn/revive can't slip past disposal and escape exit.
this.creationFenced = true
if (this.disposePromise) {
return this.disposePromise
}
this.agentSessionCreateOperations.clear()
const disposePromise = this.disposePtys(options.waitForPhysicalExit !== false)
this.disposePromise = disposePromise
void disposePromise.catch(() => {
// Why: clear on rejected kill so a later shutdown can retry instead of joining a rejected promise.
if (this.disposePromise === disposePromise) {
this.disposePromise = null
}
})
return disposePromise
}
private async disposePtys(waitForPhysicalExit: boolean): Promise<void> {
this.cancelGraceTimer()
await this.waitForPendingPtyCreations()
for (const managed of this.ptys.values()) {
this.releaseRelayIngress(managed)
this.flushPtyOutput(managed.id)
}
if (this.outputFlushTimer !== null) {
clearTimeout(this.outputFlushTimer)
this.outputFlushTimer = null
}
this.pendingOutputByPty.clear()
this.lastInputAtByPty.clear()
this.interactiveOutputCharsByPty.clear()
const results = await Promise.allSettled(
[...this.ptys.values()].map((managed) =>
this.disposePtyForRelayShutdown(managed, waitForPhysicalExit)
)
)
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
if (rejected) {
throw rejected.reason
}
}
private async disposePtyForRelayShutdown(
managed: ManagedPty,
waitForPhysicalExit: boolean
): Promise<void> {
if (managed.killTimer) {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
this.releaseRelayIngress(managed)
// Why: retain the native owner until SIGKILL is accepted (one bounded retry) or onExit proves it gone.
await this.requestForceKillForRelayShutdown(managed)
if (waitForPhysicalExit && this.ptys.get(managed.id) === managed && !managed.disposed) {
try {
await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS)
} catch {
// An accepted SIGKILL is the final boundary when an uninterruptible child can't report exit.
}
}
if (this.ptys.get(managed.id) === managed && !managed.disposed) {
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(managed.id)
this.clearPtyFlowState(managed.id)
}
}
private async requestForceKillForRelayShutdown(managed: ManagedPty): Promise<void> {
let lastError: unknown
for (let attempt = 0; attempt < PTY_FORCE_KILL_MAX_ATTEMPTS; attempt++) {
if (this.ptys.get(managed.id) !== managed || managed.disposed) {
return
}
try {
this.requestForceKill(managed)
return
} catch (error) {
lastError = error
}
if (attempt + 1 < PTY_FORCE_KILL_MAX_ATTEMPTS) {
const tracker = managed.physicalExit
if (!tracker) {
throw lastError
}
try {
await tracker.waitForExit(
PTY_FORCE_KILL_RETRY_DELAY_MS,
() => new Error(`Retrying force-kill for PTY ${managed.id}`)
)
return
} catch {
// The bounded waiter detached; retry the still-owned native handle.
}
}
}
throw lastError
}
get activePtyCount(): number {
return this.ptys.size
}
get retainedStartupCommandCount(): number {
let count = 0
for (const managed of this.ptys.values()) {
if (managed.startupCommand) {
count += 1
}
}
return count
}
get graceTimerActive(): boolean {
return this.graceTimer !== null
}
}