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