mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
* docs: design SSH relay PTY backpressure * fix(ssh): bound relay frame decoding * fix(relay): bound PTY output publication * fix(ssh): bound PTY model admission * fix(ssh): settle closed model admissions * feat(ssh): negotiate bounded PTY consumer sessions * fix(ssh): fence exit on renderer settlement * feat(ssh): track PTY source credit end to end * fix(ssh): recover bounded PTY output across reconnect * feat(ssh): complete relay PTY output backpressure * fix(ssh): close final PTY source credit races * docs(ssh): record final backpressure validation * feat(ssh): complete relay PTY source-credit lifecycle * test(ssh): complete provider notification fixture * fix(ssh): preserve terminal source credit across rotation * fix(ssh): fail closed on recovery cancellation * fix(ssh): prioritize mux control writes after drain * fix(ssh): retire canceled relay restore deliveries * fix(ssh): order exit cancellation cleanup * fix(ssh): gate provisional source activation * test(ssh): register mux drain-priority coverage * fix(ssh): type stale owner recovery mismatches * fix(ssh): close projection replacement races * fix(relay): contain streaming edge failures * fix(ssh): secure relay endpoint credentials * docs(ssh): reconcile final backpressure lifecycle * fix(ssh): bound main IPC output lifecycle * fix(ssh): close recovery ownership gaps * docs(ssh): record exact artifact validation * fix(ssh): reject reclaimed snapshot replacements * fix(ssh): fence model admission across reconnect * fix(ssh): contain migration failure per PTY * docs(ssh): record final exact-head validation * test(ssh): align deploy fixtures with credential publication * feat(ssh): add per-target bounded output setting * fix(ssh): close source recovery review gaps * fix(ssh): latch source credit environment override * feat(ssh): make PTY source credit the default * docs(ssh): record always-on relay validation * docs(ssh): bind validation to current main * test(ssh): grant source credit in IPC fixture * test(ssh): grant source credit in fake relay --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2065 lines
73 KiB
TypeScript
2065 lines
73 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 type { RelayPtySourceOutput } from './relay-pty-source-output'
|
|
import type { RelayPtySourcePublication } from './relay-pty-source-publication'
|
|
import type {
|
|
PtySourceRecoveryRequest,
|
|
PtySourceRecoveryResult
|
|
} from '../shared/pty-source-recovery-contract'
|
|
import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation'
|
|
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)
|
|
)
|
|
}
|
|
|
|
function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | undefined {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return undefined
|
|
}
|
|
const input = value as Record<string, unknown>
|
|
if (input.status === 'checkpointUnavailable') {
|
|
return Object.freeze({ status: 'checkpointUnavailable' })
|
|
}
|
|
if (
|
|
input.status !== 'checkpoint' ||
|
|
typeof input.deliveryToken !== 'string' ||
|
|
input.deliveryToken.length === 0 ||
|
|
typeof input.ptyIncarnation !== 'string' ||
|
|
input.ptyIncarnation.length === 0 ||
|
|
!Number.isSafeInteger(input.clientGeneration) ||
|
|
Number(input.clientGeneration) <= 0 ||
|
|
!Number.isSafeInteger(input.ownerGeneration) ||
|
|
Number(input.ownerGeneration) <= 0 ||
|
|
!Number.isSafeInteger(input.acceptedSourceEndSu) ||
|
|
Number(input.acceptedSourceEndSu) < 0
|
|
) {
|
|
return Object.freeze({ status: 'checkpointUnavailable' })
|
|
}
|
|
return Object.freeze({
|
|
status: 'checkpoint',
|
|
deliveryToken: input.deliveryToken,
|
|
ptyIncarnation: input.ptyIncarnation,
|
|
clientGeneration: Number(input.clientGeneration),
|
|
ownerGeneration: Number(input.ownerGeneration),
|
|
acceptedSourceEndSu: Number(input.acceptedSourceEndSu)
|
|
})
|
|
}
|
|
|
|
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
|
|
sourceActivation?: PtySourceReceivingActivation
|
|
}
|
|
|
|
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 = RelayPtySourceOutput & {
|
|
data: string
|
|
interactive?: boolean
|
|
sourceChunk?: RelayPtySourceOutput
|
|
}
|
|
|
|
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 PTY_OUTPUT_PRODUCER_HIGH_BYTES = 128 * 1024
|
|
const PTY_OUTPUT_PRODUCER_LOW_BYTES = 64 * 1024
|
|
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 pendingExitByPty = new Map<string, { id: string; code: number; incarnationId: string }>()
|
|
private pausedOutputPtys = new Set<string>()
|
|
private consumerPausedOutputPtys = new Set<string>()
|
|
private removeLegacyCapacityListener: (() => void) | null = null
|
|
private sourcePublication: RelayPtySourcePublication | null = null
|
|
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()
|
|
this.removeLegacyCapacityListener =
|
|
this.dispatcher.onLegacyPtyCapacity?.(() => this.handleLegacyCapacity()) ?? null
|
|
}
|
|
|
|
setConsumerDeliveryPaused(id: string, paused: boolean): void {
|
|
if (paused) {
|
|
this.consumerPausedOutputPtys.add(id)
|
|
this.pausePtyOutput(id)
|
|
return
|
|
}
|
|
this.consumerPausedOutputPtys.delete(id)
|
|
this.maybeResumePtyOutput(id)
|
|
}
|
|
|
|
setSourcePublication(publication: RelayPtySourcePublication): void {
|
|
this.sourcePublication = publication
|
|
}
|
|
|
|
handleSourceCreditAvailable(id: string): void {
|
|
this.sourcePublication?.onCreditAvailable(id)
|
|
}
|
|
|
|
handleSourcePublicationCapacity(id: string): void {
|
|
if (this.pendingOutputByPty.has(id)) {
|
|
this.scheduleOutputFlush(0)
|
|
}
|
|
this.maybeResumePtyOutput(id)
|
|
this.publishPendingExit(id)
|
|
}
|
|
|
|
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.pausedOutputPtys.delete(managed.id)
|
|
this.consumerPausedOutputPtys.delete(managed.id)
|
|
this.flushPtyOutput(managed.id)
|
|
this.pendingExitByPty.set(managed.id, {
|
|
id: managed.id,
|
|
code: exitCode,
|
|
incarnationId: managed.incarnationId
|
|
})
|
|
this.publishPendingExit(managed.id)
|
|
this.notifyExitListener(managed)
|
|
this.agentSessionOwners.release(managed.id)
|
|
this.ptys.delete(managed.id)
|
|
this.clearPtyInputState(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, context) => this.attach(p, context))
|
|
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 queue = this.pendingOutputByPty.get(id) ?? []
|
|
if (this.sourcePublication?.accepts(id)) {
|
|
queue.push({ data, ...meta })
|
|
this.pendingOutputByPty.set(id, queue)
|
|
if (queue.length === 1 && this.shouldSendInteractiveOutputNow(id, data)) {
|
|
queue[0].interactive = true
|
|
if (this.flushPtyOutput(id)) {
|
|
return
|
|
}
|
|
}
|
|
if (this.pendingProducerBytes(id) >= PTY_OUTPUT_PRODUCER_HIGH_BYTES) {
|
|
this.pausePtyOutput(id)
|
|
}
|
|
this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS)
|
|
return
|
|
}
|
|
const existing = queue.at(-1)
|
|
if (meta.transformed === true) {
|
|
if (queue.length === 0) {
|
|
const transformed = { data, ...meta }
|
|
if (this.publishPtyOutput(id, transformed, false)) {
|
|
return
|
|
}
|
|
queue.push(transformed)
|
|
} else if (existing?.transformed) {
|
|
existing.data += data
|
|
existing.rawLength = (existing.rawLength ?? 0) + (meta.rawLength ?? data.length)
|
|
existing.seq = meta.seq
|
|
} else {
|
|
queue.push({ data, ...meta })
|
|
}
|
|
this.pendingOutputByPty.set(id, queue)
|
|
this.pausePtyOutput(id)
|
|
return
|
|
}
|
|
const pending: PendingPtyOutput = existing && !existing.transformed ? existing : { data: '' }
|
|
const previousLength = pending.data.length
|
|
pending.data += data
|
|
if (pending.rawLength !== undefined || meta.rawLength !== undefined) {
|
|
pending.rawLength = (pending.rawLength ?? previousLength) + (meta.rawLength ?? data.length)
|
|
}
|
|
if (meta.seq !== undefined) {
|
|
pending.seq = meta.seq
|
|
}
|
|
if (!existing || existing.transformed) {
|
|
queue.push(pending)
|
|
}
|
|
this.pendingOutputByPty.set(id, queue)
|
|
if (queue.length === 1 && this.shouldSendInteractiveOutputNow(id, pending.data)) {
|
|
pending.interactive = true
|
|
if (this.flushPtyOutput(id)) {
|
|
return
|
|
}
|
|
}
|
|
if (this.pendingProducerBytes(id) >= PTY_OUTPUT_PRODUCER_HIGH_BYTES) {
|
|
this.pausePtyOutput(id)
|
|
}
|
|
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[0], next.value[1].map((pending) => ({ ...pending }))])
|
|
}
|
|
let writes = 0
|
|
for (const [id, queue] of batch) {
|
|
this.pendingOutputByPty.delete(id)
|
|
if (this.flushPtyOutput(id, queue)) {
|
|
writes++
|
|
}
|
|
}
|
|
if (this.pendingOutputByPty.size > 0 && writes > 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, capturedQueue?: PendingPtyOutput[]): boolean {
|
|
const queue = capturedQueue ?? this.pendingOutputByPty.get(id)
|
|
const pending = queue?.[0]
|
|
if (!queue || !pending) {
|
|
this.publishPendingExit(id)
|
|
return true
|
|
}
|
|
const desiredChars = pending.transformed
|
|
? pending.data.length
|
|
: Math.min(pending.data.length, PTY_OUTPUT_FLUSH_CHUNK_CHARS)
|
|
const sourceOnlyEmission =
|
|
pending.transformed === true && pending.data.length === 0 && (pending.rawLength ?? 0) > 0
|
|
const paramsWithoutData = {
|
|
id,
|
|
...(pending.seq === undefined ? {} : { seq: pending.seq }),
|
|
...(pending.rawLength === undefined ? {} : { rawLength: pending.rawLength }),
|
|
...(pending.transformed ? { transformed: true } : {})
|
|
}
|
|
let chunkChars = pending.transformed
|
|
? desiredChars
|
|
: (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ??
|
|
desiredChars)
|
|
if (
|
|
chunkChars > 0 &&
|
|
chunkChars < pending.data.length &&
|
|
pending.data.charCodeAt(chunkChars - 1) >= 0xd800 &&
|
|
pending.data.charCodeAt(chunkChars - 1) <= 0xdbff
|
|
) {
|
|
chunkChars--
|
|
}
|
|
if (
|
|
(!sourceOnlyEmission && chunkChars <= 0) ||
|
|
(pending.transformed && chunkChars !== pending.data.length)
|
|
) {
|
|
this.pendingOutputByPty.set(id, queue)
|
|
this.pausePtyOutput(id)
|
|
return false
|
|
}
|
|
const chunk = pending.data.slice(0, chunkChars)
|
|
const remaining = pending.data.slice(chunkChars)
|
|
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)
|
|
const sourceChunk =
|
|
pending.sourceChunk ??
|
|
({
|
|
data: chunk,
|
|
...(chunkSeq === undefined ? {} : { seq: chunkSeq }),
|
|
...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }),
|
|
...(pending.transformed ? { transformed: true } : {})
|
|
} satisfies RelayPtySourceOutput)
|
|
pending.sourceChunk = sourceChunk
|
|
const published = this.publishPtyOutput(id, sourceChunk, pending.interactive === true)
|
|
if (!published) {
|
|
this.pendingOutputByPty.set(id, queue)
|
|
this.pausePtyOutput(id)
|
|
return false
|
|
}
|
|
if (remaining) {
|
|
queue[0] = {
|
|
data: remaining,
|
|
...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }),
|
|
seq: pending.seq
|
|
}
|
|
} else {
|
|
queue.shift()
|
|
}
|
|
if (queue.length === 0) {
|
|
this.pendingOutputByPty.delete(id)
|
|
this.publishPendingExit(id)
|
|
} else {
|
|
this.pendingOutputByPty.set(id, queue)
|
|
}
|
|
this.maybeResumePtyOutput(id)
|
|
this.clearOutputFlushTimerIfIdle()
|
|
return true
|
|
}
|
|
|
|
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.pendingExitByPty.delete(id)
|
|
this.pausedOutputPtys.delete(id)
|
|
this.consumerPausedOutputPtys.delete(id)
|
|
this.clearPtyInputState(id)
|
|
this.clearOutputFlushTimerIfIdle()
|
|
}
|
|
|
|
private clearPtyInputState(id: string): void {
|
|
this.lastInputAtByPty.delete(id)
|
|
this.interactiveOutputCharsByPty.delete(id)
|
|
}
|
|
|
|
private publishPtyOutput(
|
|
id: string,
|
|
output: RelayPtySourceOutput,
|
|
interactive: boolean
|
|
): boolean {
|
|
if (this.sourcePublication?.accepts(id)) {
|
|
return this.sourcePublication.publish(id, output, interactive)
|
|
}
|
|
if (this.dispatcher.tryNotifyPtyData) {
|
|
return this.dispatcher.tryNotifyPtyData(
|
|
{
|
|
id,
|
|
data: output.data,
|
|
...(output.seq === undefined ? {} : { seq: output.seq }),
|
|
...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }),
|
|
...(output.transformed ? { transformed: true } : {})
|
|
},
|
|
{ interactive }
|
|
)
|
|
}
|
|
this.dispatcher.notify('pty.data', {
|
|
id,
|
|
data: output.data,
|
|
...(output.seq === undefined ? {} : { seq: output.seq }),
|
|
...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }),
|
|
...(output.transformed ? { transformed: true } : {})
|
|
})
|
|
return true
|
|
}
|
|
|
|
private publishPendingExit(id: string): void {
|
|
if (this.pendingOutputByPty.has(id)) {
|
|
return
|
|
}
|
|
const exit = this.pendingExitByPty.get(id)
|
|
if (!exit) {
|
|
return
|
|
}
|
|
if (this.sourcePublication?.accepts(id)) {
|
|
if (
|
|
!this.sourcePublication.sealAndPublishExit(exit) ||
|
|
!this.sourcePublication.exitPublicationSettled(id)
|
|
) {
|
|
return
|
|
}
|
|
this.pendingExitByPty.delete(id)
|
|
return
|
|
}
|
|
const published = this.dispatcher.tryNotifyPtyExit
|
|
? this.dispatcher.tryNotifyPtyExit(exit)
|
|
: (this.dispatcher.notify('pty.exit', exit), true)
|
|
if (!published) {
|
|
return
|
|
}
|
|
this.pendingExitByPty.delete(id)
|
|
}
|
|
|
|
private pendingProducerBytes(id: string): number {
|
|
return (this.pendingOutputByPty.get(id) ?? []).reduce(
|
|
(total, pending) =>
|
|
total + Math.max(Buffer.byteLength(pending.data, 'utf8'), 2 * pending.data.length) + 128,
|
|
0
|
|
)
|
|
}
|
|
|
|
private pausePtyOutput(id: string): void {
|
|
if (this.pausedOutputPtys.has(id)) {
|
|
return
|
|
}
|
|
const managed = this.ptys.get(id)
|
|
if (!managed || managed.disposed) {
|
|
return
|
|
}
|
|
this.pausedOutputPtys.add(id)
|
|
managed.pty.pause()
|
|
}
|
|
|
|
private maybeResumePtyOutput(id: string): void {
|
|
if (
|
|
!this.pausedOutputPtys.has(id) ||
|
|
this.consumerPausedOutputPtys.has(id) ||
|
|
this.pendingProducerBytes(id) > PTY_OUTPUT_PRODUCER_LOW_BYTES ||
|
|
this.dispatcher.legacyRetentionBelowLowWater === false
|
|
) {
|
|
return
|
|
}
|
|
const managed = this.ptys.get(id)
|
|
this.pausedOutputPtys.delete(id)
|
|
if (managed && !managed.disposed) {
|
|
managed.pty.resume()
|
|
}
|
|
}
|
|
|
|
private handleLegacyCapacity(): void {
|
|
if (this.pendingOutputByPty.size > 0) {
|
|
this.scheduleOutputFlush(0)
|
|
}
|
|
for (const id of Array.from(this.pendingExitByPty.keys())) {
|
|
this.publishPendingExit(id)
|
|
}
|
|
for (const id of Array.from(this.pausedOutputPtys)) {
|
|
this.maybeResumePtyOutput(id)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const result = await existing
|
|
this.sourcePublication?.activate(result.id, result.incarnationId, context)
|
|
const sourceActivation =
|
|
context && this.sourcePublication?.receivingActivation?.(result.id, context.clientId)
|
|
const { sourceActivation: _staleActivation, ...stableResult } = result
|
|
return { ...stableResult, ...(sourceActivation ? { sourceActivation } : {}) }
|
|
}
|
|
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() : ''
|
|
this.sourcePublication?.activate(managed.id, managed.incarnationId, context)
|
|
const sourceActivation =
|
|
context && this.sourcePublication?.receivingActivation?.(managed.id, context.clientId)
|
|
return {
|
|
id: managed.id,
|
|
incarnationId: managed.incarnationId,
|
|
agentSessionEnsure: result,
|
|
...(sourceActivation ? { sourceActivation } : {}),
|
|
...(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
|
|
sourceActivation?: PtySourceReceivingActivation
|
|
}> {
|
|
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.sourcePublication?.activate(id, managed.incarnationId, context)
|
|
const sourceActivation =
|
|
context && this.sourcePublication?.receivingActivation?.(id, context.clientId)
|
|
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,
|
|
...(sourceActivation ? { sourceActivation } : {})
|
|
}
|
|
}
|
|
|
|
private async attach(
|
|
params: Record<string, unknown>,
|
|
context?: RequestContext
|
|
): Promise<{
|
|
incarnationId: string
|
|
replay?: string
|
|
sourceRecovery?: PtySourceRecoveryResult
|
|
sourceActivation?: PtySourceReceivingActivation
|
|
}> {
|
|
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()
|
|
let sourceRecovery = parseSourceRecoveryRequest(params.sourceRecovery)
|
|
if (
|
|
sourceRecovery?.status === 'checkpoint' &&
|
|
this.sourcePublication &&
|
|
!(await this.sourcePublication.waitForPendingSend(id))
|
|
) {
|
|
sourceRecovery = Object.freeze({ status: 'checkpointUnavailable' })
|
|
}
|
|
const activation = this.sourcePublication?.activate(
|
|
id,
|
|
managed.incarnationId,
|
|
context,
|
|
sourceRecovery
|
|
)
|
|
const sourceActivation =
|
|
context && this.sourcePublication?.receivingActivation?.(id, context.clientId)
|
|
if (typeof activation === 'object') {
|
|
return {
|
|
incarnationId: managed.incarnationId,
|
|
sourceRecovery: activation,
|
|
...(sourceActivation ? { sourceActivation } : {})
|
|
}
|
|
}
|
|
if (activation === 'existing' && this.sourcePublication?.accepts(id)) {
|
|
return {
|
|
incarnationId: managed.incarnationId,
|
|
...(sourceActivation ? { sourceActivation } : {})
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
this.maybeResumePtyOutput(id)
|
|
if (params.suppressReplayNotification) {
|
|
return {
|
|
incarnationId: managed.incarnationId,
|
|
replay,
|
|
...(sourceActivation ? { sourceActivation } : {})
|
|
}
|
|
}
|
|
this.dispatcher.notify('pty.replay', { id, data: replay })
|
|
}
|
|
return {
|
|
incarnationId: managed.incarnationId,
|
|
...(sourceActivation ? { sourceActivation } : {})
|
|
}
|
|
}
|
|
|
|
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.removeLegacyCapacityListener?.()
|
|
this.removeLegacyCapacityListener = null
|
|
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.pendingExitByPty.clear()
|
|
this.pausedOutputPtys.clear()
|
|
this.consumerPausedOutputPtys.clear()
|
|
this.lastInputAtByPty.clear()
|
|
this.interactiveOutputCharsByPty.clear()
|
|
this.sourcePublication?.dispose()
|
|
this.sourcePublication = null
|
|
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
|
|
}
|
|
}
|