1781 lines
77 KiB
TypeScript
1781 lines
77 KiB
TypeScript
/* eslint-disable max-lines -- Why: PTY IPC is intentionally centralized in one
|
||
main-process module so spawn-time environment scoping, lifecycle cleanup,
|
||
foreground-process inspection, and renderer IPC stay behind a single audited
|
||
boundary. Splitting it by line count would scatter tightly coupled terminal
|
||
process behavior across files without a cleaner ownership seam. */
|
||
import { join, delimiter } from 'path'
|
||
import { randomUUID } from 'crypto'
|
||
import { type BrowserWindow, ipcMain, app } from 'electron'
|
||
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
|
||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||
import type { Store } from '../persistence'
|
||
import type { GlobalSettings } from '../../shared/types'
|
||
import { openCodeHookService } from '../opencode/hook-service'
|
||
import { agentHookServer } from '../agent-hooks/server'
|
||
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
|
||
import { isPwshAvailable } from '../pwsh'
|
||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||
import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
|
||
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
|
||
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
|
||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||
import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment'
|
||
import {
|
||
isClaudeAuthSwitchInProgress,
|
||
markClaudePtyExited,
|
||
markClaudePtySpawned
|
||
} from '../claude-accounts/live-pty-gate'
|
||
import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution'
|
||
import { registerPty, unregisterPty } from '../memory/pty-registry'
|
||
import { track } from '../telemetry/client'
|
||
import { classifyError } from '../telemetry/classify-error'
|
||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||
import {
|
||
agentKindSchema,
|
||
launchSourceSchema,
|
||
requestKindSchema
|
||
} from '../../shared/telemetry-events'
|
||
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
|
||
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
|
||
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
||
import {
|
||
isTerminalLeafId,
|
||
makePaneKey,
|
||
parseLegacyNumericPaneKey,
|
||
parsePaneKey
|
||
} from '../../shared/stable-pane-id'
|
||
import {
|
||
clearMigrationUnsupportedPty,
|
||
clearMigrationUnsupportedPtysForPaneKey
|
||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||
|
||
// ─── Provider Registry ──────────────────────────────────────────────
|
||
// Routes PTY operations by connectionId. null = local provider.
|
||
// SSH providers will be registered here in Phase 1.
|
||
|
||
let localProvider: IPtyProvider = new LocalPtyProvider()
|
||
const sshProviders = new Map<string, IPtyProvider>()
|
||
// Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent
|
||
// write/resize/kill calls only carry the PTY ID. This map lets us route
|
||
// post-spawn operations to the correct provider without the renderer needing
|
||
// to track connectionId per-PTY.
|
||
const ptyOwnership = new Map<string, string | null>()
|
||
// Why: mobile clients must mirror desktop PTY geometry even when the renderer
|
||
// cannot provide an xterm snapshot yet, such as immediately after tab creation.
|
||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||
// Why: the agent-hooks server caches per-paneKey state (last prompt, last
|
||
// tool) that otherwise grows unbounded as panes come and go. Track the
|
||
// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY
|
||
// teardown — the renderer knows the paneKey but the PTY lifecycle does not
|
||
// without this mapping.
|
||
const ptyPaneKey = new Map<string, string>()
|
||
// Why: reverse of ptyPaneKey — callers that receive a paneKey from outside the
|
||
// PTY lifecycle (e.g. the agent-hook server routing a cursor-agent status event
|
||
// back into the pane's data stream) need to find the ptyId for that paneKey.
|
||
// Kept in lock-step with ptyPaneKey via the same spawn and teardown sites.
|
||
const paneKeyPtyId = new Map<string, string>()
|
||
|
||
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
|
||
return paneKeyPtyId.get(paneKey)
|
||
}
|
||
|
||
// Why: consumers (currently the cursor-agent synthesized-spinner loop in
|
||
// main/index.ts) need to tear down paneKey-scoped state when a PTY exits so
|
||
// intervals / timers cannot leak for the process lifetime. A callback
|
||
// registry keeps the cross-module dependency narrow — clearProviderPtyState
|
||
// only has to know about "things to notify", not about every consumer's
|
||
// internals.
|
||
type PaneKeyTeardownListener = (paneKey: string) => void
|
||
const paneKeyTeardownListeners = new Set<PaneKeyTeardownListener>()
|
||
|
||
export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void {
|
||
paneKeyTeardownListeners.add(listener)
|
||
return () => paneKeyTeardownListeners.delete(listener)
|
||
}
|
||
|
||
// Why: pre-signal handshake — the renderer declares it will own the serializer
|
||
// for a paneKey BEFORE issuing pty:spawn. The cooperation gate at provider.spawn
|
||
// return consults this map to suppress the daemon-snapshot seed when a renderer
|
||
// is taking over. Generation tokens prevent paneKey-reuse races during teardown:
|
||
// a paneKeyTeardownListener cleanup only fires settle when the captured gen
|
||
// still matches, so a remount that pre-signals before the old PTY's teardown
|
||
// runs is preserved. See docs/mobile-prefer-renderer-scrollback.md.
|
||
let pendingSerializerGenSeq = 0
|
||
const pendingByPaneKey = new Map<string, number>()
|
||
// Why: at PTY spawn time we capture the gen that was pending for the spawn's
|
||
// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey
|
||
// remount that replaces the pending entry with a new gen would still get
|
||
// stomped by the old PTY's teardown firing settle on the wrong gen.
|
||
const ptyPendingGenByPtyId = new Map<string, number>()
|
||
// Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal.
|
||
// Populated on settlePaneSerializer (renderer has registered for this ptyId)
|
||
// and cleared on PTY teardown.
|
||
const rendererSerializerByPtyId = new Set<string>()
|
||
|
||
function parseValidPaneKey(paneKey: unknown): ReturnType<typeof parsePaneKey> {
|
||
if (typeof paneKey !== 'string' || paneKey.length > 256) {
|
||
return null
|
||
}
|
||
return parsePaneKey(paneKey)
|
||
}
|
||
|
||
function isValidPaneKey(paneKey: unknown): paneKey is string {
|
||
return parseValidPaneKey(paneKey) !== null
|
||
}
|
||
|
||
function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
|
||
const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : ''
|
||
if (!isValidPaneKey(normalizedPaneKey)) {
|
||
return null
|
||
}
|
||
ptyPaneKey.set(ptyId, normalizedPaneKey)
|
||
paneKeyPtyId.set(normalizedPaneKey, ptyId)
|
||
return normalizedPaneKey
|
||
}
|
||
|
||
function declarePendingPaneSerializer(paneKey: string): number {
|
||
const gen = ++pendingSerializerGenSeq
|
||
pendingByPaneKey.set(paneKey, gen)
|
||
return gen
|
||
}
|
||
|
||
function settlePendingPaneSerializer(paneKey: string, gen: number): void {
|
||
if (pendingByPaneKey.get(paneKey) === gen) {
|
||
pendingByPaneKey.delete(paneKey)
|
||
}
|
||
}
|
||
|
||
export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean {
|
||
return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey)
|
||
}
|
||
|
||
function getProvider(connectionId: string | null | undefined): IPtyProvider {
|
||
if (!connectionId) {
|
||
return localProvider
|
||
}
|
||
const provider = sshProviders.get(connectionId)
|
||
if (!provider) {
|
||
throw new Error(`No PTY provider for connection "${connectionId}"`)
|
||
}
|
||
return provider
|
||
}
|
||
|
||
function getProviderForPty(ptyId: string): IPtyProvider {
|
||
const connectionId = ptyOwnership.get(ptyId)
|
||
if (connectionId === undefined) {
|
||
return localProvider
|
||
}
|
||
return getProvider(connectionId)
|
||
}
|
||
|
||
function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined {
|
||
try {
|
||
return getProviderForPty(ptyId)
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
function normalizeNodePtySpawnError(err: unknown): Error {
|
||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||
const hintedMessage = addNodePtyRecoveryHint(rawMessage)
|
||
if (hintedMessage === rawMessage && err instanceof Error) {
|
||
return err
|
||
}
|
||
if (err instanceof Error) {
|
||
// Why: preserve the original stack/name/custom fields while returning the
|
||
// same recovery guidance as the renderer-driven pty:spawn path.
|
||
err.message = hintedMessage
|
||
return err
|
||
}
|
||
return new Error(hintedMessage)
|
||
}
|
||
|
||
function isPtyAlreadyGoneError(err: unknown): boolean {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
return isSshPtyNotFoundError(err) || /Session not found/i.test(message)
|
||
}
|
||
|
||
function finishPtyShutdown(
|
||
id: string,
|
||
connectionId: string | null | undefined,
|
||
store: Store | undefined
|
||
): void {
|
||
clearProviderPtyState(id)
|
||
if (connectionId) {
|
||
store?.markSshRemotePtyLease(connectionId, id, 'terminated')
|
||
}
|
||
ptyOwnership.delete(id)
|
||
markClaudePtyExited(id)
|
||
}
|
||
|
||
// ─── Host PTY env assembly ──────────────────────────────────────────
|
||
// Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active
|
||
// fallback in pty:spawn need the same set of host-local env injections
|
||
// (OpenCode plugin dir, agent-hook server coordinates, Pi overlay, Codex
|
||
// account home, dev-mode CLI overrides, GitHub attribution shims). They used
|
||
// to be implemented twice, which silently drifted — daemon-backed PTYs never
|
||
// got the OpenCode plugin, Pi overlay, Codex home, or dev CLI PATH prepend,
|
||
// so status dots, per-PTY Pi state, Codex account switching, and CLI→dev
|
||
// routing were all broken for daemon users (the common case).
|
||
//
|
||
// Centralizing the injections here makes future additions fail-safe: a new
|
||
// variable added to this function lands in BOTH spawn paths or NEITHER.
|
||
|
||
export type BuildPtyHostEnvOptions = {
|
||
isPackaged: boolean
|
||
userDataPath: string
|
||
selectedCodexHomePath: string | null
|
||
githubAttributionEnabled: boolean
|
||
}
|
||
|
||
function readInheritedPath(baseEnv: Record<string, string>): string {
|
||
return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? ''
|
||
}
|
||
|
||
/**
|
||
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
|
||
*
|
||
* This is the single source of truth for the env shape an Orca PTY needs
|
||
* BEFORE the provider-specific wrapper (LocalPtyProvider's TERM/LANG defaults,
|
||
* DaemonPtyAdapter's subprocess env). Callers are responsible for the SSH
|
||
* guard — if `args.connectionId` is set, do NOT call this function, because
|
||
* every injection here is either host-loopback (hook server, attribution
|
||
* shims) or references paths on the local filesystem that would be meaningless
|
||
* to a remote shell.
|
||
*/
|
||
export function buildPtyHostEnv(
|
||
id: string,
|
||
baseEnv: Record<string, string>,
|
||
opts: BuildPtyHostEnvOptions
|
||
): Record<string, string> {
|
||
// Why: the Local path passes a baseEnv that already includes process.env
|
||
// (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The
|
||
// daemon path passes only args.env since process.env propagates to the
|
||
// daemon subprocess via fork inheritance, not the IPC wire. Checking both
|
||
// sources when reading a potentially-user-provided value keeps the guards
|
||
// in lock-step across spawn paths without pushing process.env onto the
|
||
// IPC wire unnecessarily.
|
||
const preexistingOpenCodeConfigDir =
|
||
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ??
|
||
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR ??
|
||
baseEnv.OPENCODE_CONFIG_DIR ??
|
||
process.env.OPENCODE_CONFIG_DIR ??
|
||
readShellStartupEnvVar(
|
||
'OPENCODE_CONFIG_DIR',
|
||
baseEnv.HOME ?? process.env.HOME,
|
||
baseEnv.SHELL ?? process.env.SHELL
|
||
)
|
||
const preexistingPiAgentDir =
|
||
baseEnv.ORCA_PI_SOURCE_AGENT_DIR ??
|
||
process.env.ORCA_PI_SOURCE_AGENT_DIR ??
|
||
baseEnv.PI_CODING_AGENT_DIR ??
|
||
process.env.PI_CODING_AGENT_DIR ??
|
||
readShellStartupEnvVar(
|
||
'PI_CODING_AGENT_DIR',
|
||
baseEnv.HOME ?? process.env.HOME,
|
||
baseEnv.SHELL ?? process.env.SHELL
|
||
)
|
||
|
||
// Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user
|
||
// value cannot coexist with an Orca-only injection. Hand the user's value
|
||
// (when present) to the hook service and let it materialize a per-PTY
|
||
// mirror overlay that lets the user's plugins and Orca's status plugin
|
||
// load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See
|
||
// docs/opencode-config-dir-collision.md.
|
||
Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
|
||
if (baseEnv.OPENCODE_CONFIG_DIR) {
|
||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
|
||
// wrappers restore this PTY-scoped value after user startup files run.
|
||
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
|
||
if (preexistingOpenCodeConfigDir) {
|
||
// Why: terminals launched from another Orca terminal inherit the overlay
|
||
// as OPENCODE_CONFIG_DIR; keep the original source so overlays do not
|
||
// mirror overlays and drop the user's real config.
|
||
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
|
||
}
|
||
}
|
||
|
||
// Why: Claude/Codex native hooks run inside the shell process, so Orca
|
||
// must inject the loopback receiver coordinates before the agent starts.
|
||
// Without these env vars the global hook config cannot map callbacks back
|
||
// to the correct Orca pane.
|
||
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
|
||
|
||
// Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a
|
||
// PTY-scoped overlay from the caller's chosen root so Pi sessions keep
|
||
// their user state without sharing a mutable overlay across terminals.
|
||
// Under the daemon path, `id` is the daemon sessionId — the overlay
|
||
// survives daemon cold restore because the sessionId is stable across
|
||
// restarts by design. A future reader should NOT "simplify" id allocation
|
||
// back to a fresh UUID per spawn; that would discard user Pi state on
|
||
// every daemon reconnect.
|
||
Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir))
|
||
if (baseEnv.PI_CODING_AGENT_DIR) {
|
||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
|
||
// wrappers restore this PTY-scoped value after user startup files run.
|
||
baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR
|
||
if (preexistingPiAgentDir) {
|
||
// Why: preserve the original Pi root across nested Orca terminals; the
|
||
// public env var is intentionally restored to the current PTY overlay.
|
||
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = preexistingPiAgentDir
|
||
}
|
||
}
|
||
|
||
// Why: Codex account switching now materializes auth into one shared
|
||
// runtime home (~/.codex), and Codex launched inside Orca terminals must
|
||
// use that same prepared home as quota fetches and other entry points.
|
||
// Keep the override PTY-scoped so Orca does not mutate the app process
|
||
// environment or the user's unrelated external shells.
|
||
if (opts.selectedCodexHomePath) {
|
||
baseEnv.CODEX_HOME = opts.selectedCodexHomePath
|
||
}
|
||
|
||
// Why: in dev mode the `orca` CLI defaults to the production userData
|
||
// path, which routes status updates to the packaged Orca instead of this
|
||
// dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from
|
||
// agents running inside dev terminals reach the correct runtime. We also
|
||
// prepend the dev CLI launcher directory to PATH so `orca` resolves to
|
||
// the dev build (which supports ORCA_USER_DATA_PATH) instead of the
|
||
// production binary at /usr/local/bin/orca.
|
||
if (!opts.isPackaged) {
|
||
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
|
||
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
|
||
const inheritedPath = readInheritedPath(baseEnv)
|
||
// Why: avoid a trailing delimiter when PATH is empty — some shells
|
||
// treat an empty segment as `.`, which would let commands resolve from
|
||
// the current working directory (a foot-gun we don't want to create
|
||
// for dev terminals).
|
||
baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin
|
||
}
|
||
|
||
// Why: GitHub attribution should only affect commands launched from
|
||
// Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps
|
||
// the behavior local to Orca instead of rewriting user git config or
|
||
// touching external shells.
|
||
if (!opts.githubAttributionEnabled) {
|
||
delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION
|
||
delete baseEnv.ORCA_GIT_COMMIT_TRAILER
|
||
delete baseEnv.ORCA_GH_PR_FOOTER
|
||
delete baseEnv.ORCA_GH_ISSUE_FOOTER
|
||
delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||
}
|
||
applyTerminalAttributionEnv(baseEnv, {
|
||
enabled: opts.githubAttributionEnabled,
|
||
userDataPath: opts.userDataPath
|
||
})
|
||
|
||
return baseEnv
|
||
}
|
||
|
||
function isClaudeLaunchCommand(command: string | undefined): boolean {
|
||
if (!command) {
|
||
return false
|
||
}
|
||
return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test(
|
||
command
|
||
)
|
||
}
|
||
|
||
/** Register an SSH PTY provider for a connection. */
|
||
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
|
||
sshProviders.set(connectionId, provider)
|
||
}
|
||
|
||
/** Remove an SSH PTY provider when a connection is closed. */
|
||
export function unregisterSshPtyProvider(connectionId: string): void {
|
||
sshProviders.delete(connectionId)
|
||
}
|
||
|
||
/** Get the SSH PTY provider for a connection (for dispose on cleanup). */
|
||
export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined {
|
||
return sshProviders.get(connectionId)
|
||
}
|
||
|
||
/** Get the installed PTY provider (for direct access in tests/runtime).
|
||
*
|
||
* Returns the installed PTY provider — after `setLocalPtyProvider()` runs
|
||
* during daemon init this may be the routed adapter (specifically either
|
||
* `DaemonPtyAdapter` or its `DaemonPtyRouter` wrapper). Callers needing
|
||
* `LocalPtyProvider`-specific methods (`killOrphanedPtys`,
|
||
* `advanceGeneration`, `getPtyProcess`) must type-narrow or import the
|
||
* concrete class directly. */
|
||
export function getLocalPtyProvider(): IPtyProvider {
|
||
return localProvider
|
||
}
|
||
|
||
/** Replace the local PTY provider with a daemon-backed one.
|
||
* Call before registerPtyHandlers so the IPC layer routes through the daemon. */
|
||
export function setLocalPtyProvider(provider: IPtyProvider): void {
|
||
localProvider = provider
|
||
}
|
||
|
||
/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */
|
||
export function getPtyIdsForConnection(connectionId: string): string[] {
|
||
const ids: string[] = []
|
||
for (const [ptyId, connId] of ptyOwnership) {
|
||
if (connId === connectionId) {
|
||
ids.push(ptyId)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
/**
|
||
* Remove all PTY ownership entries for a given connectionId.
|
||
* Why: when an SSH connection is closed, the remote PTYs are gone but their
|
||
* ownership entries linger. Without cleanup, subsequent spawn calls could
|
||
* look up a stale provider for those PTY IDs, and the map grows unboundedly.
|
||
*/
|
||
export function clearPtyOwnershipForConnection(connectionId: string): void {
|
||
for (const [ptyId, connId] of ptyOwnership) {
|
||
if (connId === connectionId) {
|
||
// Why: remote PTYs are gone after the SSH connection closes — their
|
||
// paneKey-scoped caches (agent-hooks server, OpenCode, Pi) must be swept
|
||
// the same way a local onExit would, otherwise they leak indefinitely
|
||
// for the process lifetime.
|
||
clearProviderPtyState(ptyId)
|
||
ptyOwnership.delete(ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Provider-scoped PTY state cleanup ──────────────────────────────
|
||
|
||
export function clearProviderPtyState(id: string): void {
|
||
// Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the
|
||
// node-pty process table. Centralizing provider cleanup avoids drift where a
|
||
// new teardown path forgets to remove one provider's overlay/hook state.
|
||
openCodeHookService.clearPty(id)
|
||
piTitlebarExtensionService.clearPty(id)
|
||
ptySizes.delete(id)
|
||
const paneKey = ptyPaneKey.get(id)
|
||
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
|
||
// Why: drop the memory-collector registration so a dead PTY does not keep
|
||
// trying to resolve its (now-dead) pid on every snapshot. Safe no-op for
|
||
// PTYs that were never registered (SSH-owned).
|
||
unregisterPty(id)
|
||
clearMigrationUnsupportedPty(id)
|
||
agentHookServer.clearPaneKeyAliasesForPty(id, {
|
||
shouldClearStablePaneKey: (stablePaneKey) => {
|
||
// Why: when this PTY never rebuilt ptyPaneKey after restart, alias
|
||
// ownership is our only proof. Once a newer PTY owns the same stable
|
||
// paneKey, alias teardown must not erase that newer status.
|
||
const stablePaneOwner = paneKeyPtyId.get(stablePaneKey)
|
||
if (stablePaneOwner && stablePaneOwner !== id) {
|
||
return false
|
||
}
|
||
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
|
||
}
|
||
})
|
||
rendererSerializerByPtyId.delete(id)
|
||
// Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would
|
||
// otherwise accumulate entries for dead panes over the process lifetime.
|
||
// Use the spawn-time paneKey mapping since the server has no other way to
|
||
// correlate a ptyId back to its paneKey.
|
||
if (paneKey) {
|
||
if (stillOwnsPaneKey) {
|
||
agentHookServer.clearPaneState(paneKey)
|
||
paneKeyPtyId.delete(paneKey)
|
||
}
|
||
ptyPaneKey.delete(id)
|
||
// Why: drop the pre-signal pending entry only if it still belongs to THIS
|
||
// PTY's spawn generation. If a remount for the same paneKey has already
|
||
// pre-signaled a new gen, this teardown must NOT touch it — otherwise
|
||
// the second mount's hydration loses to the daemon-snapshot seed. See
|
||
// the generation-token rationale in
|
||
// docs/mobile-prefer-renderer-scrollback.md.
|
||
const ownedGen = ptyPendingGenByPtyId.get(id)
|
||
if (ownedGen !== undefined) {
|
||
settlePendingPaneSerializer(paneKey, ownedGen)
|
||
}
|
||
ptyPendingGenByPtyId.delete(id)
|
||
if (stillOwnsPaneKey) {
|
||
// Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId
|
||
// entries so a listener that re-reads the map sees the post-teardown
|
||
// state. Wrap each call so one throwing listener cannot block the rest.
|
||
for (const listener of paneKeyTeardownListeners) {
|
||
try {
|
||
listener(paneKey)
|
||
} catch (err) {
|
||
console.error('[pty] paneKey teardown listener threw', err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export function deletePtyOwnership(id: string): void {
|
||
ptyOwnership.delete(id)
|
||
}
|
||
|
||
export function setPtyOwnership(id: string, connectionId: string | null): void {
|
||
ptyOwnership.set(id, connectionId)
|
||
}
|
||
|
||
// Why: localProvider.onData/onExit return unsubscribe functions. Without
|
||
// storing and calling these on re-registration, macOS app re-activation
|
||
// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking
|
||
// duplicate listeners that forward every event twice.
|
||
let localDataUnsub: (() => void) | null = null
|
||
let localExitUnsub: (() => void) | null = null
|
||
let didFinishLoadHandler: (() => void) | null = null
|
||
|
||
// Why: the "Restart daemon" path needs to re-bind provider→renderer listeners
|
||
// against the freshly-created adapter after replaceDaemonProvider swaps the
|
||
// module-level `localProvider` pointer. Without this, old subscribers stay
|
||
// bound to the disposed adapter and new PTY data silently drops. Saved at
|
||
// module scope so the restart flow (src/main/daemon/daemon-init.ts) can
|
||
// trigger a rebind without re-running the full registerPtyHandlers setup.
|
||
let rebindProviderListeners: (() => void) | null = null
|
||
|
||
export function rebindLocalProviderListeners(): void {
|
||
rebindProviderListeners?.()
|
||
}
|
||
|
||
// Why: the "Restart daemon" flow needs to detach listeners from the current
|
||
// adapter *after* synthetic pty:exit events fan out (so the renderer receives
|
||
// them) but *before* replaceDaemonProvider swaps in the new adapter (so the
|
||
// new provider isn't missing bindings). This export narrows that window to
|
||
// the caller.
|
||
export function unbindLocalProviderListeners(): void {
|
||
localDataUnsub?.()
|
||
localExitUnsub?.()
|
||
localDataUnsub = null
|
||
localExitUnsub = null
|
||
}
|
||
|
||
// ─── IPC Registration ───────────────────────────────────────────────
|
||
|
||
export function registerPtyHandlers(
|
||
mainWindow: BrowserWindow,
|
||
runtime?: OrcaRuntimeService,
|
||
getSelectedCodexHomePath?: () => string | null,
|
||
getSettings?: () => GlobalSettings,
|
||
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>,
|
||
store?: Store
|
||
): void {
|
||
// Remove any previously registered handlers so we can re-register them
|
||
// (e.g. when macOS re-activates the app and creates a new window).
|
||
ipcMain.removeHandler('pty:spawn')
|
||
ipcMain.removeHandler('pty:kill')
|
||
ipcMain.removeHandler('pty:listSessions')
|
||
ipcMain.removeHandler('pty:hasChildProcesses')
|
||
ipcMain.removeHandler('pty:getForegroundProcess')
|
||
ipcMain.removeHandler('pty:getCwd')
|
||
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
|
||
ipcMain.removeHandler('pty:settlePaneSerializer')
|
||
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
|
||
ipcMain.removeAllListeners('pty:write')
|
||
ipcMain.removeAllListeners('pty:ackColdRestore')
|
||
ipcMain.removeAllListeners('pty:serializeBuffer:response')
|
||
|
||
// Configure the local provider with app-specific hooks.
|
||
// Why: only LocalPtyProvider has the configure() method — daemon-backed
|
||
// providers handle subprocess spawning internally and don't need main-process
|
||
// hook injection. The hooks (buildSpawnEnv, onSpawned, etc.) only make sense
|
||
// when the PTY lives in the Electron main process.
|
||
if (localProvider instanceof LocalPtyProvider) {
|
||
localProvider.configure({
|
||
isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true,
|
||
getWindowsShell: () => getSettings?.()?.terminalWindowsShell,
|
||
getWindowsPowerShellImplementation: () =>
|
||
getSettings
|
||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||
: undefined,
|
||
pwshAvailable: () => isPwshAvailable(),
|
||
buildSpawnEnv: (id, baseEnv) => {
|
||
const env = buildPtyHostEnv(id, baseEnv, {
|
||
isPackaged: app.isPackaged,
|
||
userDataPath: app.getPath('userData'),
|
||
selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null,
|
||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false
|
||
})
|
||
// Why: agents need their own terminal handle at process start so they
|
||
// can self-identify in orchestration messages without an extra RPC.
|
||
const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE
|
||
const preAllocatedHandle =
|
||
requestedHandle && trustedTerminalHandleEnv.has(requestedHandle)
|
||
? requestedHandle
|
||
: runtime?.preAllocateHandleForPty(id)
|
||
if (requestedHandle && requestedHandle !== preAllocatedHandle) {
|
||
delete env.ORCA_TERMINAL_HANDLE
|
||
}
|
||
if (preAllocatedHandle) {
|
||
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
|
||
}
|
||
return env
|
||
},
|
||
onSpawned: (id) => runtime?.onPtySpawned(id),
|
||
onExit: (id, code) => {
|
||
clearProviderPtyState(id)
|
||
ptyOwnership.delete(id)
|
||
markClaudePtyExited(id)
|
||
runtime?.onPtyExit(id, code)
|
||
},
|
||
onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp)
|
||
})
|
||
}
|
||
|
||
// Why: batching PTY data into short flush windows (8ms ≈ half a frame)
|
||
// reduces IPC round-trips from hundreds/sec to ~120/sec under high
|
||
// throughput, with no perceptible latency increase for interactive use.
|
||
const pendingData = new Map<string, string>()
|
||
const trustedTerminalHandleEnv = new Set<string>()
|
||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||
const PTY_BATCH_INTERVAL_MS = 8
|
||
|
||
const flushPendingData = (): void => {
|
||
flushTimer = null
|
||
if (mainWindow.isDestroyed()) {
|
||
pendingData.clear()
|
||
return
|
||
}
|
||
for (const [id, data] of pendingData) {
|
||
mainWindow.webContents.send('pty:data', { id, data })
|
||
}
|
||
pendingData.clear()
|
||
}
|
||
|
||
// Why: extracted so the "Restart daemon" flow can rebind against the fresh
|
||
// adapter after replaceDaemonProvider runs. Both the startup registration
|
||
// and the post-restart rebind go through the same code path — no risk of
|
||
// drift between the two entry points.
|
||
const bindProviderListeners = (): void => {
|
||
localDataUnsub?.()
|
||
localExitUnsub?.()
|
||
|
||
// Why: LocalPtyProvider routes data to the runtime via configure().onData,
|
||
// but daemon-backed providers don't have configure(). Without this, daemon
|
||
// PTY data never reaches the runtime's tail buffer, so terminal.read returns
|
||
// empty and agent-detection from raw data never fires. Runtime tails also
|
||
// power mobile read/stream, so they must be notified regardless of window
|
||
// state.
|
||
const isLocalProvider = localProvider instanceof LocalPtyProvider
|
||
|
||
localDataUnsub = localProvider.onData((payload) => {
|
||
if (!isLocalProvider) {
|
||
runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||
}
|
||
if (mainWindow.isDestroyed()) {
|
||
// Why: clear the pending flush timer so it doesn't fire after the window
|
||
// is gone. Without this, macOS app re-activation leaks orphaned timers
|
||
// from the previous window's registration.
|
||
if (flushTimer) {
|
||
clearTimeout(flushTimer)
|
||
flushTimer = null
|
||
}
|
||
pendingData.clear()
|
||
return
|
||
}
|
||
const existing = pendingData.get(payload.id)
|
||
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
|
||
if (!flushTimer) {
|
||
flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS)
|
||
}
|
||
})
|
||
localExitUnsub = localProvider.onExit((payload) => {
|
||
if (!isLocalProvider) {
|
||
clearProviderPtyState(payload.id)
|
||
ptyOwnership.delete(payload.id)
|
||
markClaudePtyExited(payload.id)
|
||
runtime?.onPtyExit(payload.id, payload.code)
|
||
}
|
||
if (!mainWindow.isDestroyed()) {
|
||
// Why: flush any batched data for this PTY before sending the exit event,
|
||
// otherwise the last ≤8ms of output is silently lost because the renderer
|
||
// tears down the terminal on pty:exit before the batch timer fires.
|
||
const remaining = pendingData.get(payload.id)
|
||
if (remaining) {
|
||
mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining })
|
||
pendingData.delete(payload.id)
|
||
}
|
||
mainWindow.webContents.send('pty:exit', payload)
|
||
}
|
||
})
|
||
}
|
||
|
||
bindProviderListeners()
|
||
rebindProviderListeners = bindProviderListeners
|
||
|
||
// Why: a persistent ipcMain listener with a request-ID dispatch table
|
||
// (instead of one listener per call) so concurrent serialize requests do
|
||
// not stack listeners and trip Node's MaxListeners=10 warning. Many
|
||
// sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+
|
||
// concurrent calls.
|
||
type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null
|
||
const pendingSerializeRequests = new Map<
|
||
string,
|
||
{ resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout }
|
||
>()
|
||
|
||
function settleSerializeRequest(requestId: string, result: SerializeResult): void {
|
||
const pending = pendingSerializeRequests.get(requestId)
|
||
if (!pending) {
|
||
return
|
||
}
|
||
clearTimeout(pending.timeout)
|
||
pendingSerializeRequests.delete(requestId)
|
||
pending.resolve(result)
|
||
}
|
||
|
||
ipcMain.on(
|
||
'pty:serializeBuffer:response',
|
||
(
|
||
_event,
|
||
args: {
|
||
requestId?: string
|
||
snapshot?: {
|
||
data?: unknown
|
||
cols?: unknown
|
||
rows?: unknown
|
||
lastTitle?: unknown
|
||
} | null
|
||
}
|
||
) => {
|
||
if (typeof args?.requestId !== 'string') {
|
||
return
|
||
}
|
||
const snapshot = args.snapshot
|
||
if (
|
||
snapshot &&
|
||
typeof snapshot.data === 'string' &&
|
||
typeof snapshot.cols === 'number' &&
|
||
typeof snapshot.rows === 'number'
|
||
) {
|
||
const result: { data: string; cols: number; rows: number; lastTitle?: string } = {
|
||
data: snapshot.data,
|
||
cols: snapshot.cols,
|
||
rows: snapshot.rows
|
||
}
|
||
if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) {
|
||
result.lastTitle = snapshot.lastTitle
|
||
}
|
||
settleSerializeRequest(args.requestId, result)
|
||
} else {
|
||
settleSerializeRequest(args.requestId, null)
|
||
}
|
||
}
|
||
)
|
||
|
||
function requestSerializedBuffer(
|
||
ptyId: string,
|
||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||
): Promise<SerializeResult> {
|
||
if (mainWindow.isDestroyed()) {
|
||
return Promise.resolve(null)
|
||
}
|
||
|
||
const requestId = randomUUID()
|
||
return new Promise<SerializeResult>((resolve) => {
|
||
const timeout = setTimeout(() => {
|
||
settleSerializeRequest(requestId, null)
|
||
}, 750)
|
||
pendingSerializeRequests.set(requestId, { resolve, timeout })
|
||
const payload: {
|
||
requestId: string
|
||
ptyId: string
|
||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||
} = { requestId, ptyId }
|
||
if (opts) {
|
||
payload.opts = opts
|
||
}
|
||
mainWindow.webContents.send('pty:serializeBuffer:request', payload)
|
||
})
|
||
}
|
||
|
||
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
|
||
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
|
||
// process and can become orphaned on page reload. Daemon-backed sessions
|
||
// survive renderer restarts by design — orphan cleanup would kill them.
|
||
if (localProvider instanceof LocalPtyProvider) {
|
||
const lp = localProvider
|
||
if (didFinishLoadHandler) {
|
||
mainWindow.webContents.removeListener('did-finish-load', didFinishLoadHandler)
|
||
}
|
||
didFinishLoadHandler = () => {
|
||
const killed = lp.killOrphanedPtys(lp.advanceGeneration() - 1)
|
||
for (const { id } of killed) {
|
||
clearProviderPtyState(id)
|
||
ptyOwnership.delete(id)
|
||
markClaudePtyExited(id)
|
||
runtime?.onPtyExit(id, -1)
|
||
}
|
||
}
|
||
mainWindow.webContents.on('did-finish-load', didFinishLoadHandler)
|
||
}
|
||
|
||
// Why: the runtime controller must route through getProviderForPty() so that
|
||
// CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs.
|
||
// Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs.
|
||
runtime?.setPtyController({
|
||
spawn: async (args) => {
|
||
const provider = getProvider(args.connectionId)
|
||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||
}
|
||
const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null
|
||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||
}
|
||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||
throw new Error(
|
||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||
)
|
||
}
|
||
|
||
const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider)
|
||
const sessionId = isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined
|
||
let env: Record<string, string> | undefined = claudeAuth
|
||
? { ...args.env, ...claudeAuth.envPatch }
|
||
: args.env
|
||
if (args.preAllocatedHandle) {
|
||
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
|
||
}
|
||
if (isDaemonHostSpawn && sessionId) {
|
||
if (!isSafePtySessionId(sessionId, app.getPath('userData'))) {
|
||
throw new Error('Invalid PTY session id')
|
||
}
|
||
env = buildPtyHostEnv(sessionId, env ?? {}, {
|
||
isPackaged: app.isPackaged,
|
||
userDataPath: app.getPath('userData'),
|
||
selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null,
|
||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false
|
||
})
|
||
}
|
||
|
||
const spawnOptions: PtySpawnOptions = {
|
||
cols: args.cols,
|
||
rows: args.rows,
|
||
cwd: args.cwd,
|
||
env
|
||
}
|
||
if (claudeAuth?.stripAuthEnv) {
|
||
spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
|
||
}
|
||
if (args.command !== undefined) {
|
||
spawnOptions.command = args.command
|
||
}
|
||
if (args.worktreeId !== undefined) {
|
||
spawnOptions.worktreeId = args.worktreeId
|
||
}
|
||
if (sessionId !== undefined) {
|
||
spawnOptions.sessionId = sessionId
|
||
ptySizes.set(sessionId, { cols: args.cols, rows: args.rows })
|
||
}
|
||
if (process.platform === 'win32' && !args.connectionId) {
|
||
spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell
|
||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||
: undefined
|
||
}
|
||
|
||
let result: PtySpawnResult
|
||
try {
|
||
if (args.preAllocatedHandle) {
|
||
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
|
||
}
|
||
result = await provider.spawn(spawnOptions)
|
||
} catch (err) {
|
||
if (sessionId !== undefined) {
|
||
ptySizes.delete(sessionId)
|
||
clearProviderPtyState(sessionId)
|
||
}
|
||
throw normalizeNodePtySpawnError(err)
|
||
} finally {
|
||
if (args.preAllocatedHandle) {
|
||
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
|
||
}
|
||
}
|
||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
|
||
if (args.preAllocatedHandle) {
|
||
runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle)
|
||
}
|
||
if (args.worktreeId) {
|
||
runtime?.registerPty(result.id, args.worktreeId)
|
||
}
|
||
if (isClaudeLaunch) {
|
||
markClaudePtySpawned(result.id)
|
||
}
|
||
// Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler,
|
||
// so record their spawn-time paneKey here too. Synthetic hook titles and
|
||
// paneKey-scoped cache cleanup both depend on this reverse lookup.
|
||
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
|
||
if (!args.connectionId) {
|
||
registerPty({
|
||
ptyId: result.id,
|
||
worktreeId: args.worktreeId ?? null,
|
||
sessionId: sessionId ?? null,
|
||
paneKey,
|
||
pid:
|
||
typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0
|
||
? result.pid
|
||
: null
|
||
})
|
||
}
|
||
return { id: result.id }
|
||
},
|
||
write: (ptyId, data) => {
|
||
const provider = getProviderForPty(ptyId)
|
||
try {
|
||
provider.write(ptyId, data)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
},
|
||
kill: (ptyId) => {
|
||
let provider: IPtyProvider
|
||
let connectionId: string | null | undefined
|
||
try {
|
||
connectionId = ptyOwnership.get(ptyId)
|
||
provider = getProviderForPty(ptyId)
|
||
} catch {
|
||
if (connectionId) {
|
||
// Why: runtime/CLI close can target a detached SSH PTY after its
|
||
// provider was unregistered. Tombstone the lease so reconnect does
|
||
// not revive a terminal the user explicitly closed.
|
||
finishPtyShutdown(ptyId, connectionId, store)
|
||
runtime?.onPtyExit(ptyId, -1)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
// Why: shutdown() is async but the PtyController interface is sync. Defer
|
||
// cleanup until shutdown resolves so transient SSH/daemon failures don't
|
||
// hide a still-running remote process or local daemon session.
|
||
void provider
|
||
.shutdown(ptyId, { immediate: false })
|
||
.then(() => {
|
||
finishPtyShutdown(ptyId, connectionId, store)
|
||
runtime?.onPtyExit(ptyId, -1)
|
||
})
|
||
.catch((err) => {
|
||
if (isPtyAlreadyGoneError(err)) {
|
||
finishPtyShutdown(ptyId, connectionId, store)
|
||
runtime?.onPtyExit(ptyId, -1)
|
||
return
|
||
}
|
||
console.warn(
|
||
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
||
)
|
||
// Why: callers of controller.kill must observe a kill→exit pair so
|
||
// runtime tail buffers close and agents stop treating the pane as
|
||
// live. Preserve provider/lease state so a retry can still target
|
||
// the remote PTY if it survived the transient failure.
|
||
runtime?.onPtyExit(ptyId, -1)
|
||
})
|
||
return true
|
||
},
|
||
getForegroundProcess: async (ptyId) => {
|
||
try {
|
||
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
|
||
} catch {
|
||
return null
|
||
}
|
||
},
|
||
hasChildProcesses: async (ptyId) => {
|
||
try {
|
||
return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
|
||
} catch {
|
||
return false
|
||
}
|
||
},
|
||
clearBuffer: async (ptyId) => {
|
||
// Why: desktop xterm owns local scrollback, while daemon/SSH providers
|
||
// own their own retained buffers. Clear both surfaces so mobile
|
||
// resubscribe snapshots do not resurrect cleared history.
|
||
mainWindow.webContents.send('pty:clearBuffer:request', { ptyId })
|
||
try {
|
||
await getProviderForPty(ptyId).clearBuffer(ptyId)
|
||
} catch {
|
||
/* best effort: renderer clear still handles local PTYs */
|
||
}
|
||
},
|
||
listProcesses: async () => {
|
||
const providerSessions = await Promise.all([
|
||
localProvider.listProcesses(),
|
||
...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => []))
|
||
])
|
||
return providerSessions.flat()
|
||
},
|
||
serializeBuffer: (ptyId, opts) => {
|
||
// Why: mobile xterm must start from the desktop xterm's exact screen
|
||
// state and dimensions before live TUI chunks can render correctly.
|
||
return requestSerializedBuffer(ptyId, opts)
|
||
},
|
||
hasRendererSerializer: (ptyId) => {
|
||
// Why: the runtime needs a synchronous probe so it can decide whether to
|
||
// skip the daemon-snapshot seed (the renderer will hydrate it) or run the
|
||
// seed (no renderer authoritative for this PTY). A registry write happens
|
||
// when the renderer calls registerPtySerializer; we check via the same
|
||
// pendingByPaneKey + ptyId pairing that the cooperation gate uses.
|
||
return rendererSerializerByPtyId.has(ptyId)
|
||
},
|
||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null,
|
||
resize: (ptyId, cols, rows) => {
|
||
try {
|
||
ptySizes.set(ptyId, { cols, rows })
|
||
getProviderForPty(ptyId).resize(ptyId, cols, rows)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
})
|
||
|
||
// ─── IPC Handlers (thin dispatch layer) ─────────────────────────
|
||
|
||
ipcMain.handle(
|
||
'pty:spawn',
|
||
async (
|
||
_event,
|
||
args: {
|
||
cols: number
|
||
rows: number
|
||
cwd?: string
|
||
env?: Record<string, string>
|
||
command?: string
|
||
connectionId?: string | null
|
||
worktreeId?: string
|
||
sessionId?: string
|
||
shellOverride?: string
|
||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
|
||
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
|
||
// ptyId) binding before pty:spawn returns. Only the renderer's
|
||
// user-typing-Ctrl+T daemon-host path threads these; mobile/runtime
|
||
// CLI/SSH spawns leave them undefined and the main-side guard
|
||
// short-circuits.
|
||
tabId?: string
|
||
leafId?: string
|
||
// Why: telemetry-plan.md§Agent launch semantics. The renderer
|
||
// threads what Orca was *asked* to launch through this field; main
|
||
// fires `agent_started` only after `provider.spawn` resolves. Loose
|
||
// typing on the IPC boundary because the main-side schema
|
||
// validator is the single enforcement point — `track()` will drop
|
||
// the event if any field is outside its closed enum.
|
||
telemetry?: {
|
||
agent_kind?: unknown
|
||
launch_source?: unknown
|
||
request_kind?: unknown
|
||
}
|
||
}
|
||
) => {
|
||
const provider = getProvider(args.connectionId)
|
||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||
}
|
||
const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null
|
||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||
}
|
||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||
throw new Error(
|
||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||
)
|
||
}
|
||
// Why: the daemon-backed provider replaces LocalPtyProvider and therefore
|
||
// never runs its buildSpawnEnv closure. We must assemble the same
|
||
// host-local env (OpenCode plugin, agent-hook server, Pi overlay, Codex
|
||
// home, dev CLI overrides, GitHub attribution shims) here so both spawn
|
||
// paths behave identically. buildPtyHostEnv is the shared helper that
|
||
// encapsulates the full set of injections and their order/guards.
|
||
//
|
||
// Safety: skip the entire injection when a remote (SSH) connection is in
|
||
// play. Every injection here is either host-loopback (the agent-hook
|
||
// server binds 127.0.0.1, so shipping its token to an SSH host would
|
||
// leak a loopback secret for no functional benefit) or a path on the
|
||
// local filesystem (OpenCode plugin dir, Pi overlay, Codex home, dev
|
||
// CLI bin, attribution shim dir) that would resolve to nothing — or
|
||
// something misleading — on the remote machine.
|
||
const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider)
|
||
// Why: Pi's PTY overlay is keyed on the id we pass down, and the daemon
|
||
// path needs a stable id BEFORE provider.spawn so the overlay can be
|
||
// materialized in buildPtyHostEnv. DaemonPtyAdapter.doSpawn mints an id
|
||
// the same way when sessionId is absent — lifting the mint here gives
|
||
// pty.ts the id up-front without changing daemon semantics (the daemon
|
||
// still honors opts.sessionId ?? mint()).
|
||
//
|
||
// Note: the sessionId is STABLE across daemon restarts by design —
|
||
// DaemonPtyAdapter.reconcileOnStartup reuses it so that users' live
|
||
// shells survive crashes. Keying the Pi overlay on this same id means
|
||
// the user's Pi state (auth, sessions, skills) survives daemon cold
|
||
// restore too. Do NOT "simplify" id allocation back to a fresh UUID
|
||
// per spawn; that would discard Pi state on every reconnect.
|
||
// Why: only state for ids we minted in THIS request should be cleared on
|
||
// spawn failure. If the caller supplied args.sessionId it may refer to
|
||
// an existing PTY whose state (OpenCode hooks, Pi overlay, agent-hook
|
||
// pane caches) we must not clobber on a retry/attach failure.
|
||
const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn
|
||
const effectiveSessionId =
|
||
args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
|
||
// Why: the renderer sets pane env for SSH too. Only forward it to the
|
||
// remote when the relay hook path is enabled; otherwise a newer relay
|
||
// could emit statuses this Orca build is not prepared to route.
|
||
let sshSourceEnv = args.env
|
||
if (args.connectionId && !isRemoteAgentHooksEnabled()) {
|
||
if (
|
||
sshSourceEnv &&
|
||
('ORCA_PANE_KEY' in sshSourceEnv ||
|
||
'ORCA_TAB_ID' in sshSourceEnv ||
|
||
'ORCA_WORKTREE_ID' in sshSourceEnv)
|
||
) {
|
||
const stripped = { ...sshSourceEnv }
|
||
delete stripped.ORCA_PANE_KEY
|
||
delete stripped.ORCA_TAB_ID
|
||
delete stripped.ORCA_WORKTREE_ID
|
||
sshSourceEnv = stripped
|
||
}
|
||
}
|
||
const baseEnvWithAuth = claudeAuth
|
||
? { ...sshSourceEnv, ...claudeAuth.envPatch }
|
||
: sshSourceEnv
|
||
const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY
|
||
const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey)
|
||
const verifiedPaneKey =
|
||
parsedSpawnPaneKey &&
|
||
typeof args.tabId === 'string' &&
|
||
args.tabId === parsedSpawnPaneKey.tabId &&
|
||
args.leafId === parsedSpawnPaneKey.leafId
|
||
? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId)
|
||
: null
|
||
const verifiedLeafId =
|
||
verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null
|
||
const metadataLeafId =
|
||
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
||
const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey)
|
||
const migrationUnsupportedPaneKey =
|
||
legacySpawnPaneKey &&
|
||
typeof args.tabId === 'string' &&
|
||
args.tabId === legacySpawnPaneKey.tabId &&
|
||
typeof args.leafId === 'string' &&
|
||
isTerminalLeafId(args.leafId)
|
||
? makePaneKey(args.tabId, args.leafId)
|
||
: null
|
||
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
|
||
const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
|
||
if (baseEnv && stablePaneKey) {
|
||
baseEnv.ORCA_PANE_KEY = stablePaneKey
|
||
if (typeof args.tabId === 'string') {
|
||
baseEnv.ORCA_TAB_ID = args.tabId
|
||
} else if (!args.connectionId) {
|
||
delete baseEnv.ORCA_TAB_ID
|
||
}
|
||
if (typeof args.worktreeId === 'string') {
|
||
baseEnv.ORCA_WORKTREE_ID = args.worktreeId
|
||
} else if (!args.connectionId) {
|
||
delete baseEnv.ORCA_WORKTREE_ID
|
||
}
|
||
} else if (baseEnv) {
|
||
// Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the
|
||
// key proven to match this spawn's tab+leaf may leave the IPC boundary.
|
||
delete baseEnv.ORCA_PANE_KEY
|
||
delete baseEnv.ORCA_TAB_ID
|
||
delete baseEnv.ORCA_WORKTREE_ID
|
||
}
|
||
const validatedPaneKey = stablePaneKey
|
||
const validatedLeafId = verifiedLeafId ?? metadataLeafId
|
||
let env: Record<string, string> | undefined = baseEnv
|
||
const preAllocatedHandle =
|
||
runtime && !(provider instanceof LocalPtyProvider)
|
||
? runtime.createPreAllocatedTerminalHandle()
|
||
: null
|
||
if (isDaemonHostSpawn) {
|
||
if (effectiveSessionId === undefined) {
|
||
// Should be unreachable: the expression above returns a string when
|
||
// isDaemonHostSpawn is true. Defense-in-depth in case future edits
|
||
// break this invariant.
|
||
throw new Error('Invariant violation: daemon spawn without sessionId')
|
||
}
|
||
const sessionIdForEnv = effectiveSessionId
|
||
// Why: Pi overlay paths are derived from the session id; reject
|
||
// traversal sequences / path separators so a crafted IPC payload
|
||
// cannot escape the overlay root. If the renderer ever forwards a
|
||
// malicious sessionId or worktreeId the spawn is refused before any
|
||
// filesystem side-effects run.
|
||
if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) {
|
||
throw new Error('Invalid PTY session id')
|
||
}
|
||
// Why: clone before mutating so we don't leak injections back into
|
||
// args.env (which the renderer may reuse for other IPC calls).
|
||
env = { ...baseEnv }
|
||
try {
|
||
buildPtyHostEnv(sessionIdForEnv, env, {
|
||
isPackaged: app.isPackaged,
|
||
userDataPath: app.getPath('userData'),
|
||
selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null,
|
||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false
|
||
})
|
||
} catch (err) {
|
||
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
|
||
// materialization). If it throws before we reach provider.spawn,
|
||
// clear per-PTY state so the next attempt starts clean.
|
||
//
|
||
// Only sweep state for ids we MINTED in this request — caller-
|
||
// supplied ids may refer to existing PTYs whose overlay/hook state
|
||
// must not be clobbered by a transient overlay-mkdir failure on a
|
||
// retry/attach path.
|
||
if (isMintedSessionId) {
|
||
clearProviderPtyState(sessionIdForEnv)
|
||
}
|
||
throw err
|
||
}
|
||
}
|
||
const spawnEnv = preAllocatedHandle
|
||
? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle }
|
||
: env
|
||
const envToDelete = claudeAuth?.stripAuthEnv
|
||
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
|
||
: undefined
|
||
const spawnOptions: PtySpawnOptions = {
|
||
cols: args.cols,
|
||
rows: args.rows,
|
||
cwd: args.cwd,
|
||
env: spawnEnv
|
||
}
|
||
if (envToDelete) {
|
||
spawnOptions.envToDelete = envToDelete
|
||
}
|
||
if (args.command !== undefined) {
|
||
spawnOptions.command = args.command
|
||
}
|
||
if (args.worktreeId !== undefined) {
|
||
spawnOptions.worktreeId = args.worktreeId
|
||
}
|
||
if (effectiveSessionId !== undefined) {
|
||
spawnOptions.sessionId = effectiveSessionId
|
||
}
|
||
// Why: on Windows, fall back to the persisted default-shell setting
|
||
// when the renderer didn't send a per-tab override. Without this, the
|
||
// daemon path ignores the user's "Default Shell" preference entirely —
|
||
// it just calls resolvePtyShellPath(env) which reads COMSPEC (cmd.exe)
|
||
// or falls back to PowerShell. The LocalPtyProvider already consults
|
||
// getWindowsShell(); this mirrors that on the daemon path so users who
|
||
// set WSL as default actually get WSL when pressing Ctrl+T.
|
||
const effectiveShellOverride =
|
||
args.shellOverride ??
|
||
(process.platform === 'win32' && !args.connectionId
|
||
? getSettings?.()?.terminalWindowsShell
|
||
: undefined)
|
||
if (effectiveShellOverride !== undefined) {
|
||
spawnOptions.shellOverride = effectiveShellOverride
|
||
}
|
||
if (effectiveSessionId !== undefined) {
|
||
// Why: daemon PTYs can emit prompt/startup bytes before spawn()
|
||
// resolves. Runtime headless snapshots need the real pane geometry
|
||
// for those early bytes; otherwise they default to 80x24 and wrap TUIs.
|
||
ptySizes.set(effectiveSessionId, { cols: args.cols, rows: args.rows })
|
||
}
|
||
if (process.platform === 'win32' && !args.connectionId) {
|
||
// Why: the renderer only models PowerShell as one shell family. Thread
|
||
// the persisted implementation choice through spawnOptions so both the
|
||
// in-process and daemon-backed PTY paths can resolve the same effective
|
||
// executable without inventing a fourth top-level shell.
|
||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||
: undefined
|
||
}
|
||
let result: PtySpawnResult
|
||
try {
|
||
if (preAllocatedHandle) {
|
||
trustedTerminalHandleEnv.add(preAllocatedHandle)
|
||
}
|
||
result = await provider.spawn(spawnOptions)
|
||
} catch (err) {
|
||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||
const spawnError = normalizeNodePtySpawnError(err)
|
||
if (effectiveSessionId !== undefined) {
|
||
ptySizes.delete(effectiveSessionId)
|
||
}
|
||
if (
|
||
args.connectionId &&
|
||
effectiveSessionId !== undefined &&
|
||
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
|
||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
|
||
) {
|
||
// Why: expired remote reattach means the relay has already dropped
|
||
// the backing PTY. Clear the durable lease so later session writes
|
||
// cannot restore the stale pane binding.
|
||
clearProviderPtyState(effectiveSessionId)
|
||
deletePtyOwnership(effectiveSessionId)
|
||
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionId, 'expired')
|
||
}
|
||
// Why: when buildPtyHostEnv materialized a Pi overlay for this id
|
||
// but provider.spawn failed, the overlay would leak.
|
||
if (isMintedSessionId && effectiveSessionId !== undefined) {
|
||
clearProviderPtyState(effectiveSessionId)
|
||
}
|
||
// Why: telemetry-plan.md§agent_error — when the renderer threaded
|
||
// agent_kind through args.telemetry, attribute the error to that agent.
|
||
// Otherwise fall back to sniffing the command for `claude` (the one
|
||
// agent the main process can identify on its own via the existing
|
||
// `isClaudeLaunchCommand` regex used for auth gating). Bare-shell
|
||
// catches and unknown-agent catches without renderer telemetry remain
|
||
// unattributed. The event still emits with a classified `error_class`;
|
||
// raw error messages are dropped at the telemetry validator boundary.
|
||
const rendererAgentKindParse =
|
||
args.telemetry?.agent_kind !== undefined
|
||
? agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||
: null
|
||
const errorAgentKind = rendererAgentKindParse?.success
|
||
? rendererAgentKindParse.data
|
||
: isClaudeLaunch
|
||
? ('claude-code' as const)
|
||
: null
|
||
if (errorAgentKind) {
|
||
const classified = classifyError(spawnError)
|
||
track('agent_error', {
|
||
agent_kind: errorAgentKind,
|
||
error_class: classified.error_class,
|
||
...getCohortAtEmit()
|
||
})
|
||
}
|
||
throw spawnError
|
||
} finally {
|
||
if (preAllocatedHandle) {
|
||
trustedTerminalHandleEnv.delete(preAllocatedHandle)
|
||
}
|
||
}
|
||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||
if (store && args.connectionId) {
|
||
// Why: remote PTYs live in the SSH relay grace window after Orca
|
||
// detaches. Persist their IDs immediately so reconnect can reattach
|
||
// instead of treating the tab as a fresh shell.
|
||
store.upsertSshRemotePtyLease({
|
||
targetId: args.connectionId,
|
||
ptyId: result.id,
|
||
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
|
||
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
||
...(validatedLeafId ? { leafId: validatedLeafId } : {}),
|
||
state: 'attached',
|
||
lastAttachedAt: Date.now()
|
||
})
|
||
}
|
||
if (preAllocatedHandle) {
|
||
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||
}
|
||
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
|
||
// Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217)
|
||
// for local daemon PTYs and the equivalent remote-relay race for SSH.
|
||
// The renderer's debounced session writer runs in parallel for every
|
||
// other field; patch the load-bearing (tab.ptyId, ptyIdsByLeafId)
|
||
// binding synchronously so a force-quit in the ~450 ms debounce window
|
||
// cannot orphan either daemon history or a remote relay PTY lease.
|
||
if (
|
||
(isDaemonHostSpawn || args.connectionId) &&
|
||
store &&
|
||
typeof args.worktreeId === 'string' &&
|
||
typeof args.tabId === 'string' &&
|
||
validatedLeafId !== null
|
||
) {
|
||
try {
|
||
store.persistPtyBinding({
|
||
worktreeId: args.worktreeId,
|
||
tabId: args.tabId,
|
||
leafId: validatedLeafId,
|
||
ptyId: result.id
|
||
})
|
||
} catch (err) {
|
||
console.error('[pty] failed to persist PTY binding after spawn:', err)
|
||
if (!result.isReattach) {
|
||
try {
|
||
await provider.shutdown(result.id, { immediate: true })
|
||
} catch (shutdownErr) {
|
||
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
|
||
}
|
||
clearProviderPtyState(result.id)
|
||
deletePtyOwnership(result.id)
|
||
}
|
||
if (!result.isReattach && args.connectionId && store) {
|
||
store.removeSshRemotePtyLease(args.connectionId, result.id)
|
||
}
|
||
throw new Error(createTerminalSessionStateSaveFailureMessage())
|
||
}
|
||
}
|
||
// Why: pre-signal cooperation gate — when the renderer has declared it
|
||
// will own the serializer for this paneKey, suppress the daemon-snapshot
|
||
// seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer)
|
||
// is the sole authority. The pre-signal is keyed on paneKey because at
|
||
// spawn time the renderer doesn't yet know the new ptyId. See
|
||
// docs/mobile-prefer-renderer-scrollback.md.
|
||
const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false
|
||
const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id)
|
||
// Why: capture the pending gen at spawn time so teardown for THIS PTY
|
||
// only settles its own generation. A remount that replaces the entry
|
||
// with a new gen must not be stomped by the old PTY's teardown.
|
||
if (validatedPaneKey && rendererPreSignaled) {
|
||
const gen = pendingByPaneKey.get(validatedPaneKey)
|
||
if (gen !== undefined) {
|
||
ptyPendingGenByPtyId.set(result.id, gen)
|
||
}
|
||
}
|
||
|
||
// Why: hydrate the runtime's headless emulator with the adapter's
|
||
// restore data BEFORE registerPty so any live PTY data that arrives
|
||
// concurrently lands on top of the seed instead of replacing it. Mobile
|
||
// subscribers then see the same scrollback the desktop xterm received
|
||
// via coldRestore/snapshot. Without this, mobile snapshots after a
|
||
// daemon-restored attach contain only bytes emitted since the relaunch
|
||
// and the prior agent output silently disappears.
|
||
//
|
||
// Skip when the renderer is or will be authoritative for this PTY:
|
||
// its hydration path will seed the emulator from xterm's live buffer,
|
||
// which is richer than the daemon snapshot.
|
||
if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
|
||
const seedSize =
|
||
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
|
||
? { cols: result.snapshotCols, rows: result.snapshotRows }
|
||
: undefined
|
||
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
|
||
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
|
||
} else if (
|
||
result.coldRestore &&
|
||
typeof result.coldRestore.scrollback === 'string' &&
|
||
result.coldRestore.scrollback.length > 0
|
||
) {
|
||
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize)
|
||
}
|
||
}
|
||
if (
|
||
typeof args.worktreeId === 'string' &&
|
||
args.worktreeId.length > 0 &&
|
||
args.worktreeId.length <= 512
|
||
) {
|
||
runtime?.registerPty(result.id, args.worktreeId)
|
||
}
|
||
if (isClaudeLaunch) {
|
||
markClaudePtySpawned(result.id)
|
||
}
|
||
// Why: renderer sets ORCA_PANE_KEY in `args.env` for every pane-owned
|
||
// spawn (see pty-connection.ts). Recording the mapping here lets
|
||
// clearProviderPtyState clear the agent-hooks server's per-paneKey
|
||
// caches when the PTY exits.
|
||
// Why: args.env arrives as untrusted JSON over IPC — the static
|
||
// Record<string, string> type is not actually enforced at the boundary.
|
||
// Narrow to a bounded string so malformed or oversized values cannot
|
||
// pollute ptyPaneKey or the downstream clearPaneState call.
|
||
const rememberedPaneKey = validatedPaneKey
|
||
? rememberPaneKeyForPty(result.id, validatedPaneKey)
|
||
: null
|
||
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
|
||
agentHookServer.registerPaneKeyAlias(
|
||
legacySpawnPaneKey.paneKey,
|
||
migrationUnsupportedPaneKey,
|
||
result.id
|
||
)
|
||
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
|
||
} else if (validatedPaneKey) {
|
||
if (!result.isReattach) {
|
||
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
|
||
}
|
||
}
|
||
// Why: register local PTYs (connectionId falsy) with the memory
|
||
// collector so it can walk each PTY's process subtree and attribute
|
||
// memory back to its worktree. SSH PTYs execute remotely and their
|
||
// process tree is not visible to our local `ps`, so we skip them.
|
||
if (!args.connectionId) {
|
||
// Why: providers publish the OS pid on the spawn result (both
|
||
// LocalPtyProvider and DaemonPtyAdapter). Recording it once here keeps
|
||
// the memory module from reaching back into ipc/pty on a hot path, and
|
||
// works uniformly whether the PTY is hosted in-process or by the
|
||
// daemon subprocess.
|
||
const spawnedPid = result.pid ?? null
|
||
// Why: args.worktreeId and args.sessionId arrive as untrusted IPC
|
||
// payload strings — the static type is not enforced at the boundary.
|
||
// Narrow them to bounded strings here to match the paneKey defense
|
||
// above so malformed or oversized values cannot pollute registerPty's
|
||
// maps or downstream memory-attribution lookups.
|
||
registerPty({
|
||
ptyId: result.id,
|
||
worktreeId:
|
||
typeof args.worktreeId === 'string' &&
|
||
args.worktreeId.length > 0 &&
|
||
args.worktreeId.length <= 512
|
||
? args.worktreeId
|
||
: null,
|
||
sessionId:
|
||
typeof args.sessionId === 'string' &&
|
||
args.sessionId.length > 0 &&
|
||
args.sessionId.length <= 256
|
||
? args.sessionId
|
||
: null,
|
||
paneKey: rememberedPaneKey,
|
||
pid:
|
||
typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0
|
||
? spawnedPid
|
||
: null
|
||
})
|
||
}
|
||
// Why: telemetry-plan.md§Agent launch semantics — fire `agent_started`
|
||
// only after `provider.spawn` resolved. The renderer threads
|
||
// `args.telemetry` through the spawn IPC for every launch we want to
|
||
// attribute; bare-shell tabs (no agent) leave the field undefined and
|
||
// do not produce an event. Each field is parsed against its closed
|
||
// enum here so a malformed renderer payload (or a spoofed IPC) does
|
||
// not poison the event — `safeParse` failure drops that field, and
|
||
// if any required field is missing we skip the event entirely. The
|
||
// main-side `track()` validator re-runs the schema on the full
|
||
// payload as a second defense-in-depth check.
|
||
if (args.telemetry) {
|
||
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
||
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
||
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
|
||
track('agent_started', {
|
||
agent_kind: agentKindParse.data,
|
||
launch_source: launchSourceParse.data,
|
||
request_kind: requestKindParse.data,
|
||
...getCohortAtEmit()
|
||
})
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
)
|
||
|
||
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
|
||
// Why: defense-in-depth for the mobile-presence lock. The renderer's
|
||
// xterm.onData guard already drops desktop keystrokes when mobile is
|
||
// driving, but a stale view between the main-side state flip and the
|
||
// IPC arriving in the renderer can let one keystroke slip through.
|
||
// This server-side check catches it. See
|
||
// docs/mobile-presence-lock.md.
|
||
if (runtime?.getDriver(args.id).kind === 'mobile') {
|
||
return
|
||
}
|
||
tryGetProviderForPty(args.id)?.write(args.id, args.data)
|
||
})
|
||
|
||
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
|
||
// Using ipcMain.on (not .handle) halves IPC traffic by avoiding the
|
||
// empty acknowledgement message back to the renderer.
|
||
ipcMain.removeAllListeners('pty:resize')
|
||
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||
// Why: after a desktop-fit override change, the desktop renderer's
|
||
// re-render cascade runs safeFit on ALL panes (not just the affected
|
||
// one). Background-tab panes get measured at full-width (214) instead
|
||
// of their correct split width. Suppressing ALL pty:resize during
|
||
// this window prevents the cascade from corrupting PTY dimensions.
|
||
if (runtime?.isResizeSuppressed()) {
|
||
return
|
||
}
|
||
// Why: presence-lock defense-in-depth. While mobile is driving,
|
||
// desktop-side resizes (auto-fit on window resize, split drag) must
|
||
// not reach the PTY. The renderer guard checks the driver state too,
|
||
// but this is the load-bearing layer because the renderer mirror lags
|
||
// by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles
|
||
// the safeFit cascade after take-back; this driver check handles the
|
||
// ongoing locked state. See docs/mobile-presence-lock.md.
|
||
if (runtime?.getDriver(args.id).kind === 'mobile') {
|
||
return
|
||
}
|
||
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
|
||
tryGetProviderForPty(args.id)?.resize(args.id, args.cols, args.rows)
|
||
runtime?.onExternalPtyResize(args.id, args.cols, args.rows)
|
||
})
|
||
|
||
// Why: pty:reportGeometry is a measurement-only sibling of pty:resize.
|
||
// pty:resize means "I want the PTY at this size" (a write/intent — gated
|
||
// by mobile-driver and cascade suppress). pty:reportGeometry means "the
|
||
// desktop pane I'm rendering currently measures this many cells" (a
|
||
// read/observation). Mobile-fit hold needs the latter even while the
|
||
// former is intentionally blocked: when a previously-hidden desktop
|
||
// tab becomes visible while a phone is driving, the server has no way
|
||
// to learn the real desktop dims, and resolveDesktopRestoreTarget
|
||
// returns the stale spawn default (e.g. 80×24) on Take Back. Splitting
|
||
// the channels keeps each guard simple — pty:resize keeps its mobile-
|
||
// driver gate; pty:reportGeometry never resizes the PTY, only refreshes
|
||
// the restore-target cache. See docs/mobile-fit-hold.md.
|
||
ipcMain.removeAllListeners('pty:reportGeometry')
|
||
ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => {
|
||
runtime?.recordRendererGeometry(args.id, args.cols, args.rows)
|
||
})
|
||
|
||
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore
|
||
// cache after the renderer has consumed the data. No-op for non-daemon providers.
|
||
ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => {
|
||
const provider = tryGetProviderForPty(args.id)
|
||
if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') {
|
||
provider.ackColdRestore(args.id)
|
||
}
|
||
})
|
||
|
||
ipcMain.removeAllListeners('pty:signal')
|
||
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
|
||
tryGetProviderForPty(args.id)
|
||
?.sendSignal(args.id, args.signal)
|
||
.catch(() => {})
|
||
})
|
||
|
||
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
|
||
const connectionId = ptyOwnership.get(args.id)
|
||
const provider = tryGetProviderForPty(args.id)
|
||
if (!provider && connectionId) {
|
||
// Why: detached SSH PTYs intentionally keep ownership after their
|
||
// provider is unregistered. If the user closes the pane while detached,
|
||
// make the lease non-restorable instead of reviving it on reconnect.
|
||
finishPtyShutdown(args.id, connectionId, store)
|
||
return
|
||
}
|
||
try {
|
||
await (provider ?? getProviderForPty(args.id)).shutdown(args.id, {
|
||
immediate: true,
|
||
keepHistory: args.keepHistory ?? false
|
||
})
|
||
} catch (err) {
|
||
if (!isPtyAlreadyGoneError(err)) {
|
||
// Why: a failed SSH shutdown can leave the remote process alive in
|
||
// the relay grace window; daemon failures have the same risk locally.
|
||
// Keep ownership/lease state so the user can retry.
|
||
throw err
|
||
}
|
||
/* session already dead — cleanup below handles the rest */
|
||
}
|
||
// Why: onExit clears provider state for LocalPtyProvider, but remote SSH
|
||
// and daemon shutdown paths do not emit onExit through the local provider's
|
||
// listener. Explicit cleanup is idempotent and covers already-dead PTYs.
|
||
finishPtyShutdown(args.id, connectionId, store)
|
||
})
|
||
|
||
ipcMain.handle(
|
||
'pty:listSessions',
|
||
async (): Promise<{ id: string; cwd: string; title: string }[]> => {
|
||
const providerSessions = await Promise.all([
|
||
Promise.resolve({
|
||
connectionId: null as string | null,
|
||
sessions: await localProvider.listProcesses()
|
||
}),
|
||
...Array.from(sshProviders.entries(), async ([connectionId, provider]) => ({
|
||
connectionId,
|
||
sessions: await provider.listProcesses().catch(() => [])
|
||
}))
|
||
])
|
||
const deduped = new Map<string, { id: string; cwd: string; title: string }>()
|
||
for (const { connectionId, sessions } of providerSessions) {
|
||
for (const session of sessions) {
|
||
// Why: SessionsStatusSegment kill actions only send the PTY id back
|
||
// through IPC. Rebuild ownership while listing so remote sessions
|
||
// discovered after reconnect still route to their original provider.
|
||
ptyOwnership.set(session.id, connectionId)
|
||
deduped.set(session.id, session)
|
||
}
|
||
}
|
||
return Array.from(deduped.values())
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'pty:hasChildProcesses',
|
||
async (_event, args: { id: string }): Promise<boolean> => {
|
||
return getProviderForPty(args.id).hasChildProcesses(args.id)
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'pty:getForegroundProcess',
|
||
async (_event, args: { id: string }): Promise<string | null> => {
|
||
return getProviderForPty(args.id).getForegroundProcess(args.id)
|
||
}
|
||
)
|
||
|
||
// Why: renderer needs the live shell cwd when the user presses Cmd+D so
|
||
// the new split pane inherits the source pane's cwd instead of the
|
||
// worktree root. Routed through getProviderForPty so local and SSH PTYs
|
||
// use the same code path. Providers return '' when the id is unknown or
|
||
// the platform cannot resolve a cwd (Windows); the renderer treats ''
|
||
// as "fall through to the next fallback layer".
|
||
ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
|
||
try {
|
||
return await getProviderForPty(args.id).getCwd(args.id)
|
||
} catch {
|
||
return ''
|
||
}
|
||
})
|
||
|
||
// Why: pre-signal handshake handlers. See
|
||
// docs/mobile-prefer-renderer-scrollback.md and the rationale on
|
||
// `pendingByPaneKey` above. The IPC contract is: renderer awaits declare
|
||
// (capturing the returned gen), awaits pty:spawn, then registers its
|
||
// serializer locally and calls settle (echoing the gen). On spawn rejection
|
||
// or pane unmount before settle, renderer calls clear with the same gen.
|
||
ipcMain.handle(
|
||
'pty:declarePendingPaneSerializer',
|
||
async (_event, args: { paneKey?: unknown }): Promise<number> => {
|
||
if (!isValidPaneKey(args.paneKey)) {
|
||
throw new Error('Invalid paneKey')
|
||
}
|
||
return declarePendingPaneSerializer(args.paneKey)
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'pty:settlePaneSerializer',
|
||
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
|
||
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
|
||
return
|
||
}
|
||
settlePendingPaneSerializer(args.paneKey, args.gen)
|
||
// Why: settle means the renderer has registered its serializer locally
|
||
// for whatever ptyId came back from spawn. The renderer doesn't carry
|
||
// the ptyId back through this IPC because the cooperation gate ran
|
||
// pre-spawn; instead we mark the pane as authoritative by paneKey →
|
||
// ptyId via the existing paneKeyPtyId mapping populated at spawn.
|
||
const ptyId = paneKeyPtyId.get(args.paneKey)
|
||
if (ptyId) {
|
||
rendererSerializerByPtyId.add(ptyId)
|
||
}
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'pty:clearPendingPaneSerializer',
|
||
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
|
||
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
|
||
return
|
||
}
|
||
settlePendingPaneSerializer(args.paneKey, args.gen)
|
||
}
|
||
)
|
||
}
|
||
|
||
export function registerHeadlessPtyRuntime(
|
||
runtime: OrcaRuntimeService,
|
||
getSelectedCodexHomePath?: () => string | null,
|
||
getSettings?: () => GlobalSettings,
|
||
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>,
|
||
store?: Store
|
||
): void {
|
||
// Why: headless `orca serve` has no renderer window, but the runtime still
|
||
// needs the same PTY controller and provider listeners as desktop so remote
|
||
// clients can create, stream, inspect, and stop terminals.
|
||
const headlessWindow = {
|
||
isDestroyed: () => true,
|
||
webContents: {
|
||
send: () => {},
|
||
on: () => {},
|
||
removeListener: () => {}
|
||
}
|
||
} as unknown as BrowserWindow
|
||
registerPtyHandlers(
|
||
headlessWindow,
|
||
runtime,
|
||
getSelectedCodexHomePath,
|
||
getSettings,
|
||
prepareClaudeAuth,
|
||
store
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Kill all PTY processes. Call on app quit.
|
||
*/
|
||
export function killAllPty(): void {
|
||
if (localProvider instanceof LocalPtyProvider) {
|
||
localProvider.killAll()
|
||
}
|
||
}
|