6534 lines
268 KiB
TypeScript
6534 lines
268 KiB
TypeScript
/* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */
|
|
import { join, delimiter } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { statSync } from 'node:fs'
|
|
import {
|
|
type BrowserWindow,
|
|
type IpcMainEvent,
|
|
type IpcMainInvokeEvent,
|
|
type WebContents,
|
|
ipcMain,
|
|
app,
|
|
powerMonitor
|
|
} 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 { toSshExecutionHostId } from '../../shared/execution-host'
|
|
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
|
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
|
|
import type {
|
|
PtyDeliveryWriteOff,
|
|
PtyRendererDeliveryHealthReply,
|
|
PtyRendererDeliveryStateReport
|
|
} from '../../shared/pty-renderer-delivery-health'
|
|
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
|
|
import {
|
|
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
|
scanMode2031ReplyDecision,
|
|
type Mode2031ReplyScanState
|
|
} from '../../shared/terminal-color-scheme-protocol'
|
|
import {
|
|
type PtyMainDeliveryDiagnostics,
|
|
type PtyPerPtyDeliveryDiagnostics,
|
|
EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS,
|
|
createPtyDeliveryBreadcrumbRing,
|
|
redactPtyIdForDiagnostics
|
|
} from '../../shared/pty-delivery-diagnostics'
|
|
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
|
|
import { isTuiAgent } from '../../shared/tui-agent-config'
|
|
import {
|
|
normalizeAgentProviderSession,
|
|
type AgentProviderSessionMetadata,
|
|
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 { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard'
|
|
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 { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
|
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
|
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
|
|
import {
|
|
detectExplicitPiAgentKindFromCommand,
|
|
isPiCompatibleAgentType,
|
|
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 { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
|
import {
|
|
inspectPtyProviderProcess,
|
|
inspectPtyProviderProcessForRenderer
|
|
} from '../providers/pty-process-inspection'
|
|
import {
|
|
PtyProcessListAdmission,
|
|
visitPtyProcessListingsInBatches
|
|
} from '../providers/pty-process-list-admission'
|
|
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
|
import {
|
|
SSH_SESSION_EXPIRED_ERROR,
|
|
isSshPtyIdentityMismatchError,
|
|
isSshPtyNotFoundError
|
|
} from '../providers/ssh-pty-errors'
|
|
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
|
import { createPtySpawnTiming } from './pty-spawn-timing'
|
|
import {
|
|
isSafePtySessionId,
|
|
mintPtySessionId,
|
|
ptySessionIdForAgentCreateOperation
|
|
} from '../daemon/pty-session-id'
|
|
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
|
|
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
|
|
import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe'
|
|
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 { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
|
|
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 { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness'
|
|
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
|
import {
|
|
isTerminalLeafId,
|
|
makePaneKey,
|
|
parseLegacyNumericPaneKey,
|
|
parsePaneKey
|
|
} from '../../shared/stable-pane-id'
|
|
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
|
import {
|
|
resolveTerminalStartupCwdForWorkspace,
|
|
type TerminalStartupCwdMissingDirFallback
|
|
} from '../../shared/terminal-startup-cwd'
|
|
import { isWslUncPath } from '../../shared/wsl-paths'
|
|
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
|
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
|
import {
|
|
agentSessionOwnerBindingsEqual,
|
|
ClaimedAgentPtyOwnerRegistry
|
|
} from '../../shared/claimed-agent-pty-owner'
|
|
import {
|
|
clearMigrationUnsupportedPty,
|
|
clearMigrationUnsupportedPtysForPaneKey
|
|
} from '../agent-hooks/migration-unsupported-pty-state'
|
|
import { parseWslPath } from '../wsl'
|
|
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
|
|
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
|
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
|
import { beginTerminalInstall } from './watcher-removal-gate'
|
|
import {
|
|
clearHiddenRendererPtyDeliveryState,
|
|
getHiddenRendererPtyDeliveryDebug,
|
|
getHiddenRendererPtyIds,
|
|
isHiddenPtyDeliveryGateEnabled,
|
|
isHiddenRendererPty,
|
|
markHiddenRendererPty,
|
|
recordHiddenRendererPtyDataDrop,
|
|
resetHiddenRendererPtyDeliveryDebugCounters,
|
|
resetRendererScopedHiddenPtyDeliveryState,
|
|
setRendererPtyDeliveryInterest,
|
|
shouldDropHiddenRendererPtyData,
|
|
unmarkHiddenRendererPty
|
|
} from './pty-hidden-delivery-gate'
|
|
import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue'
|
|
import {
|
|
appendPendingProjectionAdmission,
|
|
compactPendingProjectionAdmissions,
|
|
propagatePendingProjectionRemainder,
|
|
type PendingProjectionAdmissions
|
|
} from './pty-pending-projection-admissions'
|
|
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
|
|
import {
|
|
cancelSshPtySourceDelivery,
|
|
installSshPtyOutputIntake,
|
|
publishSshPtySourceAck
|
|
} from './ssh-pty-output-intake-registry'
|
|
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
|
|
import {
|
|
clearNativeWindowsConptyPty,
|
|
isNativeWindowsLocalPtySpawn,
|
|
markNativeWindowsConptyPty
|
|
} from '../runtime/terminal-model-query-authority'
|
|
import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store'
|
|
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
|
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
|
|
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
|
import {
|
|
isCodexHomeAuthReadyForLaunch,
|
|
waitForManagedCodexAuthReady
|
|
} from '../codex-accounts/managed-codex-auth-readiness'
|
|
import {
|
|
forgetCodexPaneAccount,
|
|
recordCodexPaneAccount
|
|
} from '../codex/codex-pane-account-registry'
|
|
import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account'
|
|
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
|
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
|
|
import {
|
|
environmentCodexHomeOverrideContextsEqual,
|
|
getCustomCodexHomeOverrideForLaunch,
|
|
shellStartupCodexHomeOverrideContextsEqual
|
|
} from '../codex/codex-real-home-path'
|
|
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
|
|
import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch'
|
|
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
|
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
|
|
import {
|
|
resolveSetupAgentSequenceLaunchCommand,
|
|
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
|
} from '../../shared/setup-agent-sequencing'
|
|
import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop'
|
|
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
|
import { getStartupTerminalColorQueryReplyColors } 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'
|
|
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
|
import type { PtyListedSession } from '../../shared/pty-listed-session'
|
|
|
|
// ─── Provider Registry ──────────────────────────────────────────────
|
|
// Routes PTY operations by connectionId (null = local provider).
|
|
|
|
let localProvider: IPtyProvider = new LocalPtyProvider()
|
|
type FreshLocalFallbackProvider = IPtyProvider & {
|
|
routesFreshSpawnsToLocalProvider?: true
|
|
}
|
|
const sshProviders = new Map<string, IPtyProvider>()
|
|
const sshProvidersByGeneration = new Map<number, IPtyProvider>()
|
|
|
|
type RegisteredPtyProvider = {
|
|
provider: IPtyProvider
|
|
connectionId: string | null
|
|
}
|
|
|
|
function registeredPtyProviders(): RegisteredPtyProvider[] {
|
|
return [
|
|
{ provider: localProvider, connectionId: null },
|
|
...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId }))
|
|
]
|
|
}
|
|
|
|
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
|
|
// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring.
|
|
const PRODUCER_FLOW_CONTROL_ENABLED = true
|
|
// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider.
|
|
const ptyOwnership = new Map<string, string | null>()
|
|
const ptyIncarnationById = new Map<string, string>()
|
|
|
|
export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean {
|
|
const current = ptyIncarnationById.get(payload.id)
|
|
return !current || payload.incarnationId === current
|
|
}
|
|
// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation).
|
|
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
|
// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local 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>()
|
|
let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {}
|
|
let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {}
|
|
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: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go.
|
|
const ptyPaneKey = new Map<string, string>()
|
|
// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same 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 path; keep deleting stale inherited values so older PTYs can't leak the reverted path.
|
|
'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
|
|
] as const
|
|
|
|
// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript.
|
|
const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [
|
|
'CLAUDE_CODE_CHILD_SESSION',
|
|
'CLAUDE_CODE_SESSION_ID',
|
|
'CLAUDE_CODE_BRIDGE_SESSION_ID'
|
|
] as const
|
|
|
|
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
|
|
return paneKeyPtyId.get(paneKey)
|
|
}
|
|
|
|
// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow.
|
|
type PaneKeyTeardownListener = (paneKey: string) => void
|
|
const paneKeyTeardownListeners = new Set<PaneKeyTeardownListener>()
|
|
|
|
export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void {
|
|
paneKeyTeardownListeners.add(listener)
|
|
return () => paneKeyTeardownListeners.delete(listener)
|
|
}
|
|
|
|
// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. 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 materialization and a newly-focused pane can race to spawn the same leaf; key by paneKey so the loser adopts the winner's PTY.
|
|
const paneSpawnReservationsByPaneKey = new Map<string, PaneSpawnReservation>()
|
|
// Why: one main process can route the same remote provider namespace through
|
|
// multiple SSH relays; coordinate claims above every provider boundary too.
|
|
const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
|
|
let agentSessionOwnerReconciliation: Promise<void> | null = null
|
|
|
|
function assertSpawnReplyWasLive(result: PtySpawnResult): void {
|
|
if (!result.exitedBeforeSpawnReply) {
|
|
return
|
|
}
|
|
// Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit.
|
|
throw Object.assign(new Error('agent_session_exited_during_start'), {
|
|
agentSessionOperationOutcome: 'unknown' as const
|
|
})
|
|
}
|
|
|
|
async function reconcileAgentSessionOwnerListings(): Promise<void> {
|
|
if (agentSessionOwnerReconciliation) {
|
|
return await agentSessionOwnerReconciliation
|
|
}
|
|
const reconciliation = (async () => {
|
|
const providers: { provider: IPtyProvider; connectionId: string | null }[] = [
|
|
{ provider: localProvider, connectionId: null },
|
|
...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId }))
|
|
]
|
|
const listings = await Promise.all(
|
|
providers.map(async ({ provider, connectionId }) => ({
|
|
connectionId,
|
|
sessions: await provider.listProcesses()
|
|
}))
|
|
)
|
|
const advertisedOwners: AgentSessionOwnerBinding[] = []
|
|
const advertisedOwnerSessions: {
|
|
id: string
|
|
connectionId: string | null
|
|
incarnationId: string
|
|
}[] = []
|
|
for (const { connectionId, sessions } of listings) {
|
|
for (const session of sessions) {
|
|
const incarnationId = session.incarnationId
|
|
let hasAdvertisedOwner = false
|
|
for (const owner of session.agentSessionOwners ?? []) {
|
|
if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) {
|
|
// Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit.
|
|
throw new Error('agent_session_ownership_unknown')
|
|
}
|
|
advertisedOwners.push(owner)
|
|
hasAdvertisedOwner = true
|
|
}
|
|
if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) {
|
|
advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId })
|
|
}
|
|
}
|
|
}
|
|
agentSessionOwners.reconcileAuthoritative(advertisedOwners, {
|
|
// Why: an unregistered relay can still own a live PTY during reconnect;
|
|
// only providers that serialize claims may make listing absence authoritative.
|
|
isInAuthoritativeScope: (owner) => {
|
|
const provider = tryGetProviderForAgentSessionOwner(owner.ptyId)
|
|
return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true
|
|
}
|
|
})
|
|
for (const session of advertisedOwnerSessions) {
|
|
ptyOwnership.set(session.id, session.connectionId)
|
|
ptyIncarnationById.set(session.id, session.incarnationId)
|
|
}
|
|
})()
|
|
agentSessionOwnerReconciliation = reconciliation
|
|
try {
|
|
await reconciliation
|
|
} finally {
|
|
if (agentSessionOwnerReconciliation === reconciliation) {
|
|
agentSessionOwnerReconciliation = null
|
|
}
|
|
}
|
|
}
|
|
// Why: bind the declaration generation directly to its spawn result. PTY ids
|
|
// are reusable and teardown callbacks carry no incarnation token, so teardown
|
|
// must never guess which pending renderer generation it owns.
|
|
const pendingPtyIdBySerializerGeneration = new Map<number, string>()
|
|
// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup.
|
|
const rendererSerializerReadiness = new RendererTerminalSerializerReadiness()
|
|
|
|
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)
|
|
pendingPtyIdBySerializerGeneration.delete(pending.gen)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
const replaced = pendingByPaneKey.get(paneKey)
|
|
if (replaced) {
|
|
pendingPtyIdBySerializerGeneration.delete(replaced.gen)
|
|
}
|
|
pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null })
|
|
const existingPtyId = paneKeyPtyId.get(paneKey)
|
|
if (existingPtyId) {
|
|
pendingPtyIdBySerializerGeneration.set(gen, existingPtyId)
|
|
}
|
|
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): boolean {
|
|
if (pendingByPaneKey.get(paneKey)?.gen !== gen) {
|
|
return false
|
|
}
|
|
pendingByPaneKey.delete(paneKey)
|
|
return true
|
|
}
|
|
|
|
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) {
|
|
const parsedSshId = parseAppSshPtyId(ptyId)
|
|
if (parsedSshId) {
|
|
// Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider.
|
|
return getProvider(parsedSshId.connectionId)
|
|
}
|
|
return localProvider
|
|
}
|
|
return getProvider(connectionId)
|
|
}
|
|
|
|
function hasPtyProviderForInspection(ptyId: string): boolean {
|
|
// Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise 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 closeStartupQueryAuthorityForPty(ptyId: string): void {
|
|
try {
|
|
void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch(
|
|
() => {}
|
|
)
|
|
} catch {
|
|
/* Best-effort handoff; the bounded source deadline remains the fallback. */
|
|
}
|
|
}
|
|
|
|
function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined {
|
|
const ownedConnectionId = ptyOwnership.get(ptyId)
|
|
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
|
try {
|
|
return getProvider(parsedSshId?.connectionId ?? ownedConnectionId)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function normalizeNodePtySpawnError(err: unknown): Error {
|
|
const rawMessage = err instanceof Error ? err.message : String(err)
|
|
const hintedMessage = addNodePtyRecoveryHint(rawMessage)
|
|
if (hintedMessage === rawMessage && err instanceof Error) {
|
|
return err
|
|
}
|
|
if (err instanceof Error) {
|
|
// Why: preserve the original stack/name/custom fields while adding the same recovery hint as the 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,
|
|
deadlineMs?: number
|
|
): Promise<boolean> {
|
|
// Why: bound the liveness list RPC by the teardown deadline so a wedged daemon
|
|
// fails fast; undefined keeps the provider default for all other callers.
|
|
return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some(
|
|
(session) => session.id === ptyId
|
|
)
|
|
}
|
|
|
|
async function isProviderAgentSessionOwnerLive(
|
|
provider: IPtyProvider,
|
|
owner: AgentSessionOwnerBinding
|
|
): Promise<boolean> {
|
|
const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId)
|
|
if (!session) {
|
|
return false
|
|
}
|
|
if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) {
|
|
// Why: in-process local owners cannot serialize the controller claim; exact incarnation
|
|
// liveness keeps that claim authoritative until the normal PTY exit releases it.
|
|
const expectedIncarnation = ptyIncarnationById.get(owner.ptyId)
|
|
return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation
|
|
}
|
|
return Boolean(
|
|
session.agentSessionOwners?.some((candidate) =>
|
|
agentSessionOwnerBindingsEqual(candidate, owner)
|
|
)
|
|
)
|
|
}
|
|
|
|
async function verifyPtyStopped(
|
|
provider: IPtyProvider,
|
|
ptyId: string,
|
|
opts: { keepHistory?: boolean; deadlineMs?: number } | undefined
|
|
): Promise<boolean> {
|
|
if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) {
|
|
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, opts?.deadlineMs)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function finishPtyShutdown(
|
|
id: string,
|
|
connectionId: string | null | undefined,
|
|
store: Store | undefined
|
|
): string | undefined {
|
|
const incarnationId = ptyIncarnationById.get(id)
|
|
clearProviderPtyState(id)
|
|
if (connectionId) {
|
|
store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated')
|
|
}
|
|
ptyOwnership.delete(id)
|
|
markClaudePtyExited(id)
|
|
return incarnationId
|
|
}
|
|
|
|
// ─── Host PTY env assembly ──────────────────────────────────────────
|
|
// Why: centralize host-local env injections so both spawn paths (local + daemon) get them; implemented twice they drifted, silently breaking daemon PTYs.
|
|
|
|
export type BuildPtyHostEnvOptions = {
|
|
isPackaged: boolean
|
|
userDataPath: string
|
|
selectedCodexHomePath: string | null
|
|
skipCodexHomeEnv?: boolean
|
|
/** System-default real-home routing (flag ON): inject no managed CODEX_HOME,
|
|
* and strip only an inherited Orca-owned override so nested Orca panes do not
|
|
* leak the parent's managed home. A user-set CODEX_HOME is preserved. */
|
|
stripInheritedOrcaCodexHome?: boolean
|
|
githubAttributionEnabled: boolean
|
|
/** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent
|
|
* extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from
|
|
* disk presence (cross-agent shadowing when both dirs exist). */
|
|
launchCommand?: string
|
|
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
|
|
launchAgent?: TuiAgent
|
|
shellPath?: string
|
|
isWsl?: boolean
|
|
/** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */
|
|
wslDistro?: string | null
|
|
agentStatusHooksEnabled: boolean
|
|
networkProxySettings?: NetworkProxySettings
|
|
/** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */
|
|
deferGitConfigGuardToDaemon?: boolean
|
|
}
|
|
|
|
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 prepends Orca's 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)
|
|
}
|
|
|
|
// Why: with the real-home flag ON, a host system-default launch resolves to a
|
|
// null managed home. Signal the env builder to strip a nested-Orca-inherited
|
|
// override instead of injecting one, so Codex runs on the user's own ~/.codex.
|
|
function shouldStripInheritedOrcaCodexHome(args: {
|
|
target: CodexAccountSelectionTarget
|
|
selectedCodexHomePath: string | null
|
|
skipCodexHomeEnv: boolean
|
|
settings: GlobalSettings | undefined
|
|
}): boolean {
|
|
return (
|
|
args.target.runtime === 'host' &&
|
|
args.selectedCodexHomePath === null &&
|
|
!args.skipCodexHomeEnv &&
|
|
isCodexSystemDefaultRealHomeEnabled()
|
|
)
|
|
}
|
|
|
|
const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const
|
|
|
|
// Why: system-default real-home routing runs Codex on the user's own ~/.codex.
|
|
// Nested Orca panes inherit the parent's Orca-owned override; strip only that
|
|
// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop
|
|
// the marker so a shell-ready wrapper cannot restore the managed home. A
|
|
// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606).
|
|
function stripInheritedOrcaCodexHomeOverride(baseEnv: Record<string, string>): void {
|
|
for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) {
|
|
delete baseEnv[key]
|
|
}
|
|
}
|
|
|
|
// Why: in-process spawns share main's inherited environment, so equality with
|
|
// the private marker is authoritative here. Persistent daemons compare locally.
|
|
function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): string[] {
|
|
const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME
|
|
const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME
|
|
const keysToDelete = ['ORCA_CODEX_HOME']
|
|
if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) {
|
|
keysToDelete.push('CODEX_HOME')
|
|
}
|
|
return keysToDelete
|
|
}
|
|
|
|
export type CodexHomeLaunchContext = {
|
|
workspacePath?: string
|
|
launchAgent?: TuiAgent
|
|
unavailableManagedHomePath?: string
|
|
}
|
|
|
|
export type GetSelectedCodexHomePath = (
|
|
target?: CodexAccountSelectionTarget,
|
|
launchEnv?: NodeJS.ProcessEnv,
|
|
launchContext?: CodexHomeLaunchContext
|
|
) => string | null
|
|
export type PrepareCodexSessionResume = (args: {
|
|
providerSession: AgentProviderSessionMetadata
|
|
target: CodexAccountSelectionTarget
|
|
launchEnv?: NodeJS.ProcessEnv
|
|
workspacePath?: string
|
|
}) => Promise<CodexSessionResumePreparation | 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
|
|
}
|
|
|
|
const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE =
|
|
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
|
|
const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE =
|
|
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
|
|
|
|
type ManagedCodexAuthResolutionArgs = {
|
|
selectedCodexHomePath: string | null
|
|
getSettings: () => GlobalSettings | undefined
|
|
requiredCodexHomePath?: string
|
|
target: CodexAccountSelectionTarget
|
|
resolveCurrent: () => string | null
|
|
resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null
|
|
}
|
|
|
|
export function resolveCodexHomeAfterManagedAuthReadiness(
|
|
args: ManagedCodexAuthResolutionArgs
|
|
): string | null | Promise<string | null> {
|
|
const selectedCodexHomePath = args.selectedCodexHomePath
|
|
if (
|
|
args.requiredCodexHomePath &&
|
|
!codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath)
|
|
) {
|
|
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const readiness = waitForManagedCodexAuthReady({
|
|
codexHomePath: selectedCodexHomePath,
|
|
settings: args.getSettings(),
|
|
target: args.target
|
|
})
|
|
return readiness
|
|
? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness)
|
|
: selectedCodexHomePath
|
|
}
|
|
|
|
async function continueCodexHomeAfterManagedAuthWait(
|
|
args: ManagedCodexAuthResolutionArgs,
|
|
initialCodexHomePath: string | null,
|
|
initialReadiness: Promise<boolean>
|
|
): Promise<string | null> {
|
|
let selectedCodexHomePath = initialCodexHomePath
|
|
let readiness = initialReadiness
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
if (await readiness) {
|
|
if (args.requiredCodexHomePath) {
|
|
return selectedCodexHomePath
|
|
}
|
|
const currentCodexHomePath = args.resolveCurrent()
|
|
if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) {
|
|
return selectedCodexHomePath
|
|
}
|
|
selectedCodexHomePath = currentCodexHomePath
|
|
if (attempt === 1) {
|
|
break
|
|
}
|
|
const nextReadiness = waitForManagedCodexAuthReady({
|
|
codexHomePath: selectedCodexHomePath,
|
|
settings: args.getSettings(),
|
|
target: args.target
|
|
})
|
|
if (!nextReadiness) {
|
|
return selectedCodexHomePath
|
|
}
|
|
readiness = nextReadiness
|
|
continue
|
|
}
|
|
if (args.requiredCodexHomePath) {
|
|
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
|
|
}
|
|
selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!)
|
|
if (attempt === 1) {
|
|
break
|
|
}
|
|
const nextReadiness = waitForManagedCodexAuthReady({
|
|
codexHomePath: selectedCodexHomePath,
|
|
settings: args.getSettings(),
|
|
target: args.target
|
|
})
|
|
if (!nextReadiness) {
|
|
return selectedCodexHomePath
|
|
}
|
|
readiness = nextReadiness
|
|
}
|
|
if (
|
|
isCodexHomeAuthReadyForLaunch({
|
|
codexHomePath: selectedCodexHomePath,
|
|
settings: args.getSettings(),
|
|
target: args.target
|
|
})
|
|
) {
|
|
return selectedCodexHomePath
|
|
}
|
|
throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE)
|
|
}
|
|
|
|
function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean {
|
|
return (
|
|
left === right ||
|
|
(left !== null &&
|
|
right !== null &&
|
|
normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right))
|
|
)
|
|
}
|
|
|
|
function codexHomePathsEqual(left: string | null, right: string): boolean {
|
|
return codexHomeSelectionsEqual(left, right)
|
|
}
|
|
|
|
// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon
|
|
// keeps that shell alive across app restarts, so the launch account is the only
|
|
// way to tell later that a pane still runs Codex as the previously selected
|
|
// account. A reattach inherits that baked environment rather than choosing one,
|
|
// so re-recording it under the current selection would erase the very evidence
|
|
// that the pane is stale.
|
|
function recordCodexPaneAccountForSpawn(args: {
|
|
ptyId: string | undefined
|
|
isDaemonHostSpawn: boolean
|
|
isReattach: boolean
|
|
pinnedByResume: boolean
|
|
launchCodexHomePath: string | null
|
|
launchEnv?: NodeJS.ProcessEnv
|
|
target: CodexAccountSelectionTarget
|
|
settings: GlobalSettings | undefined
|
|
}): void {
|
|
if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) {
|
|
return
|
|
}
|
|
const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv)
|
|
const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null
|
|
const recheckableEnvironmentOverride =
|
|
customHomeOverride?.source === 'environment' &&
|
|
processHomeOverride?.source === 'environment' &&
|
|
environmentCodexHomeOverrideContextsEqual(
|
|
customHomeOverride.context,
|
|
processHomeOverride.context
|
|
)
|
|
? customHomeOverride.context
|
|
: undefined
|
|
const recheckableShellStartupOverride =
|
|
customHomeOverride?.source === 'shell-startup' &&
|
|
processHomeOverride?.source === 'shell-startup' &&
|
|
shellStartupCodexHomeOverrideContextsEqual(
|
|
customHomeOverride.context,
|
|
processHomeOverride.context
|
|
)
|
|
? customHomeOverride.context
|
|
: undefined
|
|
const record = args.settings
|
|
? resolveCodexPaneLaunchAccount({
|
|
pinnedByResume: args.pinnedByResume,
|
|
launchCodexHomePath: args.launchCodexHomePath,
|
|
// Why: pane-local overrides cannot be re-derived when a restart builds
|
|
// a fresh launch env, so route prompts would guess and block valid input.
|
|
recordComparableHomeRoute:
|
|
args.pinnedByResume ||
|
|
((customHomeOverride?.source !== 'environment' ||
|
|
recheckableEnvironmentOverride !== undefined) &&
|
|
(customHomeOverride?.source !== 'shell-startup' ||
|
|
recheckableShellStartupOverride !== undefined)),
|
|
shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride,
|
|
environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride,
|
|
systemCodexHomePath: getSystemCodexHomePath(),
|
|
settings: args.settings,
|
|
target: args.target
|
|
})
|
|
: null
|
|
if (!record) {
|
|
forgetCodexPaneAccount(args.ptyId)
|
|
return
|
|
}
|
|
recordCodexPaneAccount(args.ptyId, record)
|
|
}
|
|
|
|
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 a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; 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
|
|
}
|
|
}
|
|
|
|
// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type.
|
|
function mergePtyEnvDeletions(
|
|
existingKeys: string[] | undefined,
|
|
...additionalKeyGroups: readonly (readonly string[])[]
|
|
): string[] | undefined {
|
|
if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) {
|
|
return undefined
|
|
}
|
|
return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()]))
|
|
}
|
|
|
|
function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined {
|
|
// Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account.
|
|
const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME')
|
|
return filtered?.length ? filtered : undefined
|
|
}
|
|
|
|
function getInheritedAgentHookEnvKeysToDelete(
|
|
spawnEnv: Record<string, string> | undefined
|
|
): string[] {
|
|
const env = spawnEnv ?? {}
|
|
// Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set.
|
|
return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined)
|
|
}
|
|
|
|
function getInheritedClaudeSessionStampEnvKeysToDelete(
|
|
spawnEnv: Record<string, string> | undefined
|
|
): string[] {
|
|
const env = spawnEnv ?? {}
|
|
// Why: strip only values inherited from the pty host; a caller that explicitly
|
|
// provides a stamp (deliberately spawning a nested Claude child) keeps it.
|
|
return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined)
|
|
}
|
|
|
|
// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values.
|
|
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: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir.
|
|
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.
|
|
*
|
|
* Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback
|
|
* or references local filesystem paths 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: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths.
|
|
const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv)
|
|
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
|
|
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
|
|
? opts.launchAgent
|
|
: opts.launchAgent === undefined
|
|
? detectExplicitPiAgentKindFromCommand(launchCommandHint)
|
|
: null
|
|
const piAgentKind = explicitPiAgentKind ?? 'pi'
|
|
const hasLaunchCommand =
|
|
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
|
|
|
|
// Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior.
|
|
applyTerminalGitCredentialPromptGuard(baseEnv, {
|
|
launchCommand: launchCommandHint,
|
|
isUnattended: opts.launchAgent !== undefined,
|
|
deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon
|
|
})
|
|
|
|
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
|
|
// Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing.
|
|
const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi')
|
|
const preexistingOmpAgentDir =
|
|
piAgentKind === 'omp'
|
|
? resolvePiAgentSourceDir(baseEnv, 'omp')
|
|
: resolveScopedPiAgentSourceDir(baseEnv, 'omp')
|
|
|
|
if (opts.agentStatusHooksEnabled) {
|
|
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. 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.
|
|
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
|
|
if (preexistingOpenCodeConfigDir) {
|
|
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
|
|
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: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane.
|
|
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
|
|
delete baseEnv[key]
|
|
}
|
|
if (opts.agentStatusHooksEnabled) {
|
|
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
|
|
if (opts.isWsl === true) {
|
|
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
|
|
const distro = opts.wslDistro ?? null
|
|
wslHookRelayManager.ensureForDistro(distro)
|
|
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
|
if (guestEndpoint) {
|
|
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
|
|
}
|
|
// Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized.
|
|
const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro)
|
|
if (opencodeOverlayDir) {
|
|
baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir
|
|
baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir
|
|
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
|
} else {
|
|
// Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression).
|
|
delete baseEnv.OPENCODE_CONFIG_DIR
|
|
delete baseEnv.ORCA_OPENCODE_CONFIG_DIR
|
|
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it.
|
|
if (opts.agentStatusHooksEnabled) {
|
|
clearPiAgentShadowEnv(baseEnv, 'pi')
|
|
clearPiAgentShadowEnv(baseEnv, 'omp')
|
|
// Why: bare shells historically defaulted to Pi + OMP shadow prep and
|
|
// created ~/.<agent>/agent even when the user never launches those agents
|
|
// (#10196). Only create default homes on an explicit Pi/OMP launch;
|
|
// otherwise install only into an existing agent dir (or userData for OMP
|
|
// status so a typed `omp` still gets the shell wrapper extension).
|
|
if (piAgentKind === 'pi') {
|
|
const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', {
|
|
materializeDefaultHome: explicitPiAgentKind === 'pi'
|
|
})
|
|
Object.assign(baseEnv, piEnv)
|
|
exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv)
|
|
}
|
|
|
|
if (shouldPrepareOmpShadow) {
|
|
const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', {
|
|
materializeDefaultHome: explicitPiAgentKind === 'omp'
|
|
})
|
|
Object.assign(baseEnv, ompEnv)
|
|
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
|
|
}
|
|
} else {
|
|
// Why: strip BOTH kinds' shadow vars so a nested PTY can't 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: keep the Codex home override PTY-scoped so dev/prod Orcas don't 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 launches.
|
|
baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath
|
|
} else if (opts.stripInheritedOrcaCodexHome) {
|
|
stripInheritedOrcaCodexHomeOverride(baseEnv)
|
|
}
|
|
|
|
// Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance.
|
|
if (opts.isWsl) {
|
|
baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath
|
|
// Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim.
|
|
baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev'
|
|
} else {
|
|
if (!opts.isPackaged) {
|
|
baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath
|
|
}
|
|
delete baseEnv.ORCA_CLI_COMMAND
|
|
}
|
|
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
|
|
if (!opts.isPackaged) {
|
|
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
|
|
const inheritedPath = readInheritedPath(baseEnv)
|
|
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
|
|
baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin
|
|
} else if (process.platform === 'linux') {
|
|
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
|
|
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
|
|
if (shimDir) {
|
|
const inheritedEntries = readInheritedPath(baseEnv)
|
|
.split(delimiter)
|
|
.filter((entry) => entry.length > 0 && entry !== shimDir)
|
|
baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter)
|
|
}
|
|
}
|
|
|
|
// Why: PATH shims keep GitHub attribution scoped to Orca's own PTYs without rewriting user git config.
|
|
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
|
|
}
|
|
|
|
function beginPtySpawnForWorktree(
|
|
worktreeId: string | undefined,
|
|
cwd: string | undefined,
|
|
connectionId: string | null | undefined
|
|
): () => void {
|
|
const worktreePath = worktreeId
|
|
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
|
|
: undefined
|
|
const installPaths = new Map<string, string>()
|
|
for (const candidate of [worktreePath, cwd]) {
|
|
if (candidate) {
|
|
installPaths.set(normalizeRuntimePathForComparison(candidate), candidate)
|
|
}
|
|
}
|
|
const finishes: (() => void)[] = []
|
|
try {
|
|
for (const candidate of installPaths.values()) {
|
|
finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined))
|
|
}
|
|
} catch (error) {
|
|
// Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting.
|
|
finishes.toReversed().forEach((finish) => finish())
|
|
throw error
|
|
}
|
|
return () => finishes.toReversed().forEach((finish) => finish())
|
|
}
|
|
|
|
/** Register an SSH PTY provider for a connection. */
|
|
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
|
|
sshProviders.set(connectionId, provider)
|
|
const generation = (provider as { providerGeneration?: number }).providerGeneration
|
|
if (Number.isSafeInteger(generation) && generation! > 0) {
|
|
sshProvidersByGeneration.set(generation!, provider)
|
|
}
|
|
}
|
|
|
|
/** Remove an SSH PTY provider when a connection is closed. */
|
|
export function unregisterSshPtyProvider(connectionId: string): void {
|
|
const provider = sshProviders.get(connectionId)
|
|
const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration
|
|
if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) {
|
|
sshProvidersByGeneration.delete(generation)
|
|
}
|
|
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).
|
|
* After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider;
|
|
* callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */
|
|
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 transient PTY routing entries for a disconnected connection.
|
|
* Claimed agent owners remain fenced because the relay process may survive and
|
|
* prove the exact same generation after reconnect.
|
|
*/
|
|
export function clearPtyOwnershipForConnection(connectionId: string): void {
|
|
for (const [ptyId, connId] of ptyOwnership) {
|
|
if (connId === connectionId) {
|
|
// Why: pane-scoped caches cannot route while disconnected, but claimed
|
|
// ownership must survive until reconnect makes absence authoritative.
|
|
clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true })
|
|
ptyOwnership.delete(ptyId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Provider-scoped PTY state cleanup ──────────────────────────────
|
|
|
|
export function clearProviderPtyState(
|
|
id: string,
|
|
opts: { preserveAgentSessionOwners?: boolean } = {}
|
|
): void {
|
|
if (!opts.preserveAgentSessionOwners) {
|
|
agentSessionOwners.release(id)
|
|
// Why: the launch-account record outlives the app, so only a real teardown
|
|
// may drop it — a disconnect that can reconnect is not a death, and a reused
|
|
// id must never inherit a dead pane's Codex account.
|
|
forgetCodexPaneAccount(id)
|
|
}
|
|
// 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/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards.
|
|
markClaudePtyExited(id)
|
|
ptySizes.delete(id)
|
|
ptyIncarnationById.delete(id)
|
|
lastInputAtByPty.delete(id)
|
|
interactiveOutputCharsByPty.delete(id)
|
|
const activeChanged = activeRendererPtys.delete(id)
|
|
visibleRendererPtys.delete(id)
|
|
rendererVisibilityKnownPtys.delete(id)
|
|
pendingHiddenRendererResizeOutputPtys.delete(id)
|
|
deliveredHiddenRendererResizeOutputPtys.delete(id)
|
|
// Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one.
|
|
const deliveryPolicyChanged = isHiddenRendererPty(id)
|
|
clearHiddenRendererPtyDeliveryState(id)
|
|
if (activeChanged) {
|
|
invalidatePendingPtyDrainPriority(id, false)
|
|
}
|
|
if (deliveryPolicyChanged) {
|
|
invalidatePendingPtyDrainPolicy(id, false)
|
|
}
|
|
clearBackgroundedDeliverySyncForPty(id)
|
|
providerSnapshotRequiredPtys.delete(id)
|
|
// Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id.
|
|
clearNativeWindowsConptyPty(id)
|
|
const paneKey = ptyPaneKey.get(id)
|
|
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
|
|
// Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs.
|
|
unregisterPty(id)
|
|
// Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else 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 — don't erase a newer PTY that now owns the same stable paneKey.
|
|
const stablePaneOwner = paneKeyPtyId.get(stablePaneKey)
|
|
if (stablePaneOwner && stablePaneOwner !== id) {
|
|
return false
|
|
}
|
|
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
|
|
}
|
|
})
|
|
// Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime.
|
|
if (paneKey) {
|
|
if (stillOwnsPaneKey) {
|
|
agentHookServer.clearPaneState(paneKey)
|
|
paneKeyPtyId.delete(paneKey)
|
|
}
|
|
ptyPaneKey.delete(id)
|
|
if (stillOwnsPaneKey) {
|
|
// Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't 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)
|
|
}
|
|
|
|
export function restorePtyIncarnation(id: string, incarnationId: string): void {
|
|
if (!isPtyIncarnationId(incarnationId)) {
|
|
throw new Error('Invalid PTY incarnation')
|
|
}
|
|
ptyIncarnationById.set(id, incarnationId)
|
|
}
|
|
|
|
// 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 localBackgroundStreamUnsub: (() => void) | null = null
|
|
let localWriteUnavailableUnsub: (() => 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 hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved).
|
|
let rendererGateResetLoadHandler: (() => void) | null = null
|
|
let rendererGateResetGoneHandler: (() => void) | null = null
|
|
let rendererGateResetWebContents: WebContents | null = null
|
|
// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState.
|
|
let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {}
|
|
// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit.
|
|
const providerSnapshotRequiredPtys = new Set<string>()
|
|
// Why: did-start-loading also fires for in-page subframe loads (notebook srcDoc iframes); a dedicated handler filters those via isLoadingMainFrame.
|
|
let rendererDidStartLoadingHandler: (() => void) | null = null
|
|
|
|
// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops.
|
|
let rebindProviderListeners: (() => void) | null = null
|
|
let sshOutputIntakeCleanup: (() => 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
|
|
hiddenDeliveryGatedPtyCount: number
|
|
/** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */
|
|
hiddenDeliveryGatedVisiblePtyCount: number
|
|
hiddenDeliveryGatedActivePtyCount: number
|
|
deliveryInterestPtyCount: number
|
|
hiddenDeliveryDroppedChars: number
|
|
hiddenDeliveryDroppedChunks: number
|
|
pendingDroppedChars: number
|
|
/** One-paste freeze diagnostics: per-pty delivery table + event history. */
|
|
diagnostics: PtyMainDeliveryDiagnostics
|
|
// Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes.
|
|
rendererLifecycleResetCount: number
|
|
lastLifecycleResetClearedChars: number
|
|
// Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal.
|
|
rendererPtyDispatcherReady: boolean
|
|
rendererDispatcherReadyForcedCount: number
|
|
}
|
|
|
|
// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets).
|
|
const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing()
|
|
let lastPowerSuspendAtMs: number | null = null
|
|
let lastPowerResumeAtMs: number | null = null
|
|
let powerSignalBreadcrumbsInstalled = false
|
|
|
|
// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake.
|
|
function installPowerSignalBreadcrumbs(): void {
|
|
if (powerSignalBreadcrumbsInstalled) {
|
|
return
|
|
}
|
|
powerSignalBreadcrumbsInstalled = true
|
|
powerMonitor.on('suspend', () => {
|
|
lastPowerSuspendAtMs = Date.now()
|
|
mainDeliveryBreadcrumbs.record('power-suspend')
|
|
})
|
|
powerMonitor.on('resume', () => {
|
|
lastPowerResumeAtMs = Date.now()
|
|
mainDeliveryBreadcrumbs.record('power-resume')
|
|
})
|
|
}
|
|
|
|
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,
|
|
hiddenDeliveryGatedPtyCount: 0,
|
|
hiddenDeliveryGatedVisiblePtyCount: 0,
|
|
hiddenDeliveryGatedActivePtyCount: 0,
|
|
deliveryInterestPtyCount: 0,
|
|
hiddenDeliveryDroppedChars: 0,
|
|
hiddenDeliveryDroppedChunks: 0,
|
|
pendingDroppedChars: 0,
|
|
diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS,
|
|
rendererLifecycleResetCount: 0,
|
|
lastLifecycleResetClearedChars: 0,
|
|
rendererPtyDispatcherReady: false,
|
|
rendererDispatcherReadyForcedCount: 0
|
|
}
|
|
|
|
let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
|
|
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
|
|
})
|
|
let resetPtyRendererDeliveryDebugSnapshot = (): void => {}
|
|
// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash.
|
|
let resetRendererDeliveryAccountingForLifecycleReset = (): void => {}
|
|
// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own.
|
|
let clearRendererDispatcherReadyWatchdog = (): 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 {
|
|
// A reload/crash in the breadcrumb history is load-bearing context for any freeze report.
|
|
mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset')
|
|
// Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports.
|
|
const activePriorityChanged = activeRendererPtys.size > 0
|
|
activeRendererPtys.clear()
|
|
visibleRendererPtys.clear()
|
|
// Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash.
|
|
resetRendererDeliveryAccountingForLifecycleReset()
|
|
if (activePriorityChanged) {
|
|
invalidatePendingPtyDrainPriority()
|
|
}
|
|
}
|
|
|
|
function clearRendererLifecycleResetHandlers(): void {
|
|
if (!rendererLifecycleResetWebContents) {
|
|
return
|
|
}
|
|
if (rendererDidStartLoadingHandler) {
|
|
rendererLifecycleResetWebContents.removeListener(
|
|
'did-start-loading',
|
|
rendererDidStartLoadingHandler
|
|
)
|
|
}
|
|
if (rendererLifecycleResetHandler) {
|
|
rendererLifecycleResetWebContents.removeListener(
|
|
'render-process-gone',
|
|
rendererLifecycleResetHandler
|
|
)
|
|
rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler)
|
|
}
|
|
rendererLifecycleResetWebContents = null
|
|
rendererLifecycleResetHandler = null
|
|
rendererDidStartLoadingHandler = null
|
|
}
|
|
|
|
function registerRendererLifecycleResetHandlers(webContents: WebContents): void {
|
|
clearRendererLifecycleResetHandlers()
|
|
markRendererPtysHiddenForRendererLifecycleReset()
|
|
rendererLifecycleResetWebContents = webContents
|
|
rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset
|
|
// Why: did-start-loading also fires for in-page subframe loads (notebook srcDoc iframes); filter via isLoadingMainFrame so a subframe load can't clear pendingData and freeze the alive page.
|
|
rendererDidStartLoadingHandler = () => {
|
|
if (!webContents.isLoadingMainFrame()) {
|
|
return
|
|
}
|
|
markRendererPtysHiddenForRendererLifecycleReset()
|
|
}
|
|
webContents.on('did-start-loading', rendererDidStartLoadingHandler)
|
|
webContents.on('render-process-gone', rendererLifecycleResetHandler)
|
|
webContents.on('destroyed', rendererLifecycleResetHandler)
|
|
}
|
|
|
|
function clearRendererGateResetHandlers(): void {
|
|
if (rendererGateResetWebContents) {
|
|
if (rendererGateResetLoadHandler) {
|
|
rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler)
|
|
}
|
|
if (rendererGateResetGoneHandler) {
|
|
rendererGateResetWebContents.removeListener(
|
|
'render-process-gone',
|
|
rendererGateResetGoneHandler
|
|
)
|
|
}
|
|
}
|
|
rendererGateResetLoadHandler = null
|
|
rendererGateResetGoneHandler = null
|
|
rendererGateResetWebContents = null
|
|
}
|
|
|
|
// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller.
|
|
export function unbindLocalProviderListeners(): void {
|
|
localDataUnsub?.()
|
|
localExitUnsub?.()
|
|
localBackgroundStreamUnsub?.()
|
|
localWriteUnavailableUnsub?.()
|
|
localDataUnsub = null
|
|
localExitUnsub = null
|
|
localBackgroundStreamUnsub = null
|
|
localWriteUnavailableUnsub = null
|
|
}
|
|
|
|
// ─── IPC Registration ───────────────────────────────────────────────
|
|
|
|
export function registerPtyHandlers(
|
|
mainWindow: BrowserWindow,
|
|
runtime?: OrcaRuntimeService,
|
|
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
|
|
getSettings?: () => GlobalSettings,
|
|
prepareClaudeAuth?: PrepareClaudeAuth,
|
|
store?: Store,
|
|
options?: {
|
|
prepareCodexSessionResume?: PrepareCodexSessionResume
|
|
awaitLocalPtyStartup?: () => Promise<void>
|
|
awaitLocalPtyProviderStartup?: () => Promise<void>
|
|
// Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787).
|
|
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
|
}
|
|
): void {
|
|
// Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure.
|
|
clearRendererDispatcherReadyWatchdog()
|
|
resetRendererDeliveryAccountingForLifecycleReset = () => {}
|
|
invalidatePendingPtyDrainPriority = () => {}
|
|
invalidatePendingPtyDrainPolicy = () => {}
|
|
registerRendererLifecycleResetHandlers(mainWindow.webContents)
|
|
|
|
const getLocalPtyStartupPromise = (connectionId?: string | null): Promise<void> | undefined => {
|
|
if (connectionId) {
|
|
return undefined
|
|
}
|
|
// Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon.
|
|
return options?.awaitLocalPtyStartup?.()
|
|
}
|
|
|
|
const getLocalPtyProviderStartupPromise = (
|
|
connectionId?: string | null
|
|
): Promise<void> | undefined => {
|
|
if (connectionId) {
|
|
return undefined
|
|
}
|
|
return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.()
|
|
}
|
|
|
|
// Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register.
|
|
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:inspectProcess')
|
|
ipcMain.removeHandler('pty:confirmForegroundProcess')
|
|
ipcMain.removeHandler('pty:getCwd')
|
|
ipcMain.removeHandler('pty:getSize')
|
|
ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities')
|
|
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
|
|
ipcMain.removeHandler('pty:settlePaneSerializer')
|
|
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
|
|
ipcMain.removeHandler('pty:reportRendererSerializerReady')
|
|
ipcMain.removeHandler('pty:getMainBufferSnapshot')
|
|
ipcMain.removeHandler('pty:sideEffectSnapshot')
|
|
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
|
|
ipcMain.removeHandler('pty:resetRendererDeliveryDebug')
|
|
ipcMain.removeHandler('pty:reportRendererDeliveryState')
|
|
ipcMain.removeHandler('pty:writeAccepted')
|
|
ipcMain.removeAllListeners('pty:write')
|
|
ipcMain.removeAllListeners('pty:ackColdRestore')
|
|
ipcMain.removeAllListeners('pty:ackData')
|
|
ipcMain.removeAllListeners('pty:deliveryResyncResponse')
|
|
ipcMain.removeAllListeners('pty:serializeBuffer:response')
|
|
|
|
// Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally.
|
|
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,
|
|
ctx?.codexHomePathOverride
|
|
? ctx.codexHomePathOverride.value
|
|
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
|
workspacePath: ctx?.cwd,
|
|
launchAgent: ctx?.launchAgent
|
|
}) ?? null)
|
|
)
|
|
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
|
|
const env = buildPtyHostEnv(id, baseEnv, {
|
|
isPackaged: app.isPackaged,
|
|
userDataPath: app.getPath('userData'),
|
|
selectedCodexHomePath,
|
|
skipCodexHomeEnv,
|
|
stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({
|
|
target: codexSelectionTarget,
|
|
selectedCodexHomePath,
|
|
skipCodexHomeEnv,
|
|
settings: getSettings?.()
|
|
}),
|
|
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
|
launchCommand: ctx?.command,
|
|
launchAgent: ctx?.launchAgent,
|
|
shellPath: ctx?.shellPath,
|
|
isWsl: ctx?.isWsl,
|
|
wslDistro: ctx?.wslDistro ?? null,
|
|
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
|
networkProxySettings: getSettings?.()
|
|
})
|
|
// Why: agents need their terminal handle at process start to 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
|
|
}
|
|
stampWslOrchestrationCompatibilityHost(
|
|
env,
|
|
runtime?.getOrchestrationCompatibilityHostId?.(),
|
|
ctx?.isWsl === true ? ctx.wslDistro : null
|
|
)
|
|
if (ctx?.isWsl === true) {
|
|
addOrcaWslInteropEnv(env)
|
|
}
|
|
return env
|
|
},
|
|
onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId),
|
|
onExit: (id, code, incarnationId) => {
|
|
if (!isCurrentPtyExit({ id, incarnationId })) {
|
|
return
|
|
}
|
|
clearProviderPtyState(id)
|
|
ptyOwnership.delete(id)
|
|
markClaudePtyExited(id)
|
|
runtime?.onPtyExit(id, code, incarnationId)
|
|
},
|
|
onData: (id, data, timestamp, sequenceChars, transformed) =>
|
|
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
|
|
})
|
|
}
|
|
|
|
type PtyDataPayload = {
|
|
id: string
|
|
data: string
|
|
seq?: number
|
|
rawLength?: number
|
|
transformed?: boolean
|
|
background?: boolean
|
|
droppedOutput?: boolean
|
|
}
|
|
|
|
// Why: bounded batch windows amortize renderer IPC; keystroke echo/redraws bypass them below.
|
|
const pendingData = new PtyPendingDataDrainQueue(
|
|
(id) => {
|
|
const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background'
|
|
// Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted.
|
|
if (shouldDropHiddenRendererPtyData(id, getSettings?.())) {
|
|
return runnableLane
|
|
}
|
|
if (
|
|
!rendererPtyDispatcherReady ||
|
|
!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })
|
|
) {
|
|
return 'blocked'
|
|
}
|
|
return runnableLane
|
|
},
|
|
() => isHiddenPtyDeliveryGateEnabled(getSettings?.())
|
|
)
|
|
let sshOutputIntake: SshPtyOutputIntake | null = null
|
|
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
|
|
const rendererExitingPtyIds = new Set<string>()
|
|
const rendererCreditBeforeExitByPty = new Map<string, boolean>()
|
|
const rendererDeliveryRestoreNeededPtys = new Set<string>()
|
|
|
|
function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) {
|
|
const settings = getSettings?.()
|
|
const wasDroppable = shouldDropHiddenRendererPtyData(id, settings)
|
|
let droppedWhileHidden = false
|
|
if (hidden) {
|
|
markHiddenRendererPty(id)
|
|
} else {
|
|
droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden
|
|
}
|
|
const droppable = shouldDropHiddenRendererPtyData(id, settings)
|
|
return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable }
|
|
}
|
|
|
|
function transitionSpawnHiddenRendererPtyDeliveryState(id: string, hidden: boolean): void {
|
|
const transition = transitionHiddenRendererPtyDeliveryState(id, hidden)
|
|
if (transition.policyChanged) {
|
|
invalidatePendingPtyDrainPolicy(id)
|
|
}
|
|
}
|
|
// Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once.
|
|
const pendingOverflowMarkedPtys = new Set<string>()
|
|
// Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt.
|
|
type RendererPtyDeliveryAccounting = {
|
|
sentChars: number
|
|
ackedChars: number
|
|
lastSendAtMs: number
|
|
lastAckAtMs: number | null
|
|
}
|
|
const rendererDeliveryAccountingByPty = new Map<string, RendererPtyDeliveryAccounting>()
|
|
const trustedTerminalHandleEnv = new Set<string>()
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
let pendingDataFlushActive = false
|
|
let pendingDataCreditReleasedDuringFlush = false
|
|
let rendererInFlightTotalChars = 0
|
|
let pendingDroppedChars = 0
|
|
let deliveryResyncRequestSerial = 0
|
|
let deliveryResyncOutstandingRequestId: number | null = null
|
|
let deliveryResyncTimer: ReturnType<typeof setTimeout> | null = null
|
|
let deliveryResyncUnansweredWarnLogged = false
|
|
let lastAckReceivedAtMs: number | null = null
|
|
// Why 2ms: pairs with the daemon stream batcher (daemon-stream-data-batcher.ts); keeps flood coalescing at negligible IPC overhead while cutting the pipeline's latency tax.
|
|
const PTY_BATCH_INTERVAL_MS = 2
|
|
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
|
|
// Why: cap unbounded pendingData growth when the renderer can't receive (frozen/reloading); beyond it bytes drop and the pane heals from the main buffer snapshot via the droppedOutput sentinel (#7630).
|
|
// Why read settings live: the cap scales with the user's scrollback so power users don't lose lines their scrollback would have retained.
|
|
const pendingDataCapChars = (): number =>
|
|
terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows)
|
|
// Why: self-heal bound — if a reloaded page never sends pty:rendererDispatcherReady, force sends on after this window so a lost handshake can't become a permanent hold.
|
|
const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000
|
|
const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024
|
|
// Why: reserve a bounded lane so an active pane's keystroke redraw reaches the renderer ahead of hidden bulk output's ACKs.
|
|
const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024
|
|
// Why: request/response hygiene only (never mutates delivery state) — clears the outstanding-probe flag so a later arrival can re-probe, logs once per silent streak.
|
|
const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000
|
|
// Why: a heal write-off destroys delivery accounting; require this much main-side ACK silence (independent of renderer evidence) before declaring the channel dead.
|
|
const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000
|
|
// Why: keep the immediate path to keystroke-sized TUI redraws; large/non-interactive output must 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
|
|
let rendererLifecycleResetCount = 0
|
|
let lastLifecycleResetClearedChars = 0
|
|
// Why: count of watchdog gate force-opens (no handshake arrived); nonzero flags a dropped-handshake self-heal.
|
|
let rendererDispatcherReadyForcedCount = 0
|
|
// Why: gate sends until the page's pty:data listener exists; else webContents.send drops bytes but still counts them in-flight, permanently pinning the gate.
|
|
let rendererPtyDispatcherReady = false
|
|
let dispatcherReadyWatchdogTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Why: watermark producer pause/resume keyed on per-PTY pendingData (in-flight is already ACK-bounded); providers without pauseProducer no-op, memory still bounded by the pending cap.
|
|
const producerFlowControl = new PtyProducerFlowController({
|
|
pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id),
|
|
resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id)
|
|
})
|
|
const sourceCreditPendingPtys = new Set<string>()
|
|
|
|
function updateProducerFlowControl(id: string): void {
|
|
if (!PRODUCER_FLOW_CONTROL_ENABLED) {
|
|
return
|
|
}
|
|
if (sourceCreditPendingPtys.has(id)) {
|
|
if (pendingData.get(id)) {
|
|
return
|
|
}
|
|
sourceCreditPendingPtys.delete(id)
|
|
}
|
|
producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0)
|
|
}
|
|
|
|
// Why: hidden ptys are exempt from pendingData flow control, so background agents can run 100MB+ ahead in the daemon stream buffer; this sync tells the provider transport which ptys to keep-tail thin.
|
|
// Why keyed on the visibility registry (not gate marks): thinning asks "does any visible view show this PTY?"; remote-view subscribers consume raw bytes, so their presence vetoes thinning.
|
|
const backgroundedDeliverySyncByPty = new Map<string, boolean>()
|
|
function syncPtyBackgroundedDelivery(id: string, caller: string): void {
|
|
const background =
|
|
rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false)
|
|
if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) {
|
|
return
|
|
}
|
|
const provider = tryGetProviderForPty(id)
|
|
if (!provider?.setPtyBackgrounded) {
|
|
return
|
|
}
|
|
recordDaemonStreamBacklogEvent('mainBackgroundSync', {
|
|
sessionIdSuffix: id.slice(-10),
|
|
background,
|
|
caller,
|
|
known: rendererVisibilityKnownPtys.has(id),
|
|
visible: visibleRendererPtys.has(id)
|
|
})
|
|
backgroundedDeliverySyncByPty.set(id, background)
|
|
provider.setPtyBackgrounded(id, background)
|
|
}
|
|
clearBackgroundedDeliverySyncForPty = (id: string) => {
|
|
backgroundedDeliverySyncByPty.delete(id)
|
|
}
|
|
if (runtime) {
|
|
runtime.onRemoteTerminalViewPresenceChanged = (id) =>
|
|
syncPtyBackgroundedDelivery(id, 'remote-view')
|
|
}
|
|
function resyncBackgroundedDeliveriesAfterGateReset(): void {
|
|
for (const id of backgroundedDeliverySyncByPty.keys()) {
|
|
syncPtyBackgroundedDelivery(id, 'gate-reset')
|
|
}
|
|
}
|
|
|
|
function getRendererInFlightCharsForPty(id: string): number {
|
|
const accounting = rendererDeliveryAccountingByPty.get(id)
|
|
return accounting ? accounting.sentChars - accounting.ackedChars : 0
|
|
}
|
|
|
|
// Why touched PTY only: pressure peaks are monotonic between explicit resets.
|
|
function recordPtyRendererDeliveryPressure(id: string): void {
|
|
peakPendingChars = Math.max(peakPendingChars, pendingData.totalPendingChars)
|
|
peakMaxPendingCharsByPty = Math.max(
|
|
peakMaxPendingCharsByPty,
|
|
pendingData.get(id)?.data.length ?? 0
|
|
)
|
|
peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars)
|
|
peakMaxRendererInFlightCharsByPty = Math.max(
|
|
peakMaxRendererInFlightCharsByPty,
|
|
getRendererInFlightCharsForPty(id)
|
|
)
|
|
}
|
|
|
|
function setPendingPtyData(id: string, pending: PendingPtyData): void {
|
|
pendingData.set(id, pending)
|
|
recordPtyRendererDeliveryPressure(id)
|
|
}
|
|
|
|
function deletePendingPtyData(id: string): void {
|
|
pendingData.delete(id)
|
|
}
|
|
|
|
function clearPendingPtyData(): void {
|
|
for (const pending of pendingData.values()) {
|
|
if (pending.projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(
|
|
pending.projectionAdmissionIds,
|
|
'renderer-lifecycle-reset'
|
|
)
|
|
}
|
|
}
|
|
pendingData.clear()
|
|
sourceCreditPendingPtys.clear()
|
|
}
|
|
|
|
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)
|
|
}
|
|
const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug()
|
|
let rendererInFlightPtyCount = 0
|
|
let maxRendererInFlightCharsByPty = 0
|
|
for (const accounting of rendererDeliveryAccountingByPty.values()) {
|
|
const inFlight = accounting.sentChars - accounting.ackedChars
|
|
if (inFlight > 0) {
|
|
rendererInFlightPtyCount++
|
|
}
|
|
maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight)
|
|
}
|
|
// Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead).
|
|
let hiddenDeliveryGatedVisiblePtyCount = 0
|
|
for (const id of visibleRendererPtys) {
|
|
if (isHiddenRendererPty(id)) {
|
|
hiddenDeliveryGatedVisiblePtyCount++
|
|
}
|
|
}
|
|
let hiddenDeliveryGatedActivePtyCount = 0
|
|
for (const id of activeRendererPtys) {
|
|
if (isHiddenRendererPty(id)) {
|
|
hiddenDeliveryGatedActivePtyCount++
|
|
}
|
|
}
|
|
return {
|
|
pendingPtyCount: pendingData.size,
|
|
pendingChars,
|
|
maxPendingCharsByPty,
|
|
rendererInFlightPtyCount,
|
|
rendererInFlightChars: rendererInFlightTotalChars,
|
|
maxRendererInFlightCharsByPty,
|
|
activeRendererPtyCount: activeRendererPtys.size,
|
|
flushScheduled: flushTimer !== null,
|
|
peakPendingChars,
|
|
peakMaxPendingCharsByPty,
|
|
peakRendererInFlightChars,
|
|
peakMaxRendererInFlightCharsByPty,
|
|
ackGatedFlushSkipCount,
|
|
...hiddenDeliveryDebug,
|
|
hiddenDeliveryGatedVisiblePtyCount,
|
|
hiddenDeliveryGatedActivePtyCount,
|
|
pendingDroppedChars,
|
|
diagnostics: buildMainDeliveryDiagnostics(),
|
|
rendererLifecycleResetCount,
|
|
lastLifecycleResetClearedChars,
|
|
rendererPtyDispatcherReady,
|
|
rendererDispatcherReadyForcedCount
|
|
}
|
|
}
|
|
|
|
const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30
|
|
|
|
// Built only when the debug snapshot is read (never on the data path): the per-pty table + breadcrumb history says WHICH pty is wedged and WHEN, unlike aggregate counters.
|
|
function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics {
|
|
const now = Date.now()
|
|
// Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface.
|
|
const ids = new Set([
|
|
...rendererDeliveryAccountingByPty.keys(),
|
|
...pendingData.keys(),
|
|
...getHiddenRendererPtyIds(),
|
|
...visibleRendererPtys,
|
|
...activeRendererPtys
|
|
])
|
|
const perPty: PtyPerPtyDeliveryDiagnostics[] = []
|
|
for (const id of ids) {
|
|
const accounting = rendererDeliveryAccountingByPty.get(id)
|
|
perPty.push({
|
|
id: redactPtyIdForDiagnostics(id),
|
|
sentChars: accounting?.sentChars ?? 0,
|
|
ackedChars: accounting?.ackedChars ?? 0,
|
|
inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0,
|
|
pendingChars: pendingData.get(id)?.data.length ?? 0,
|
|
hidden: isHiddenRendererPty(id),
|
|
visible: visibleRendererPtys.has(id),
|
|
active: activeRendererPtys.has(id),
|
|
msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null,
|
|
msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs
|
|
})
|
|
}
|
|
perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars))
|
|
const windowAlive = !mainWindow.isDestroyed()
|
|
return {
|
|
appVersion: app.getVersion(),
|
|
mainUptimeMs: Math.round(process.uptime() * 1000),
|
|
windowFocused: windowAlive ? mainWindow.isFocused() : null,
|
|
windowVisible: windowAlive ? mainWindow.isVisible() : null,
|
|
windowMinimized: windowAlive ? mainWindow.isMinimized() : null,
|
|
msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs,
|
|
msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs,
|
|
perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS),
|
|
breadcrumbs: mainDeliveryBreadcrumbs.snapshot()
|
|
}
|
|
}
|
|
|
|
// Why rate-limited: the contradiction persists chunk after chunk while latched; one line per minute keeps field logs readable but present.
|
|
let lastHiddenDropContradictionWarnAtMs = 0
|
|
function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void {
|
|
if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) {
|
|
return
|
|
}
|
|
// Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way.
|
|
mainDeliveryBreadcrumbs.record('hidden-drop-visible', {
|
|
id: redactPtyIdForDiagnostics(id),
|
|
droppedChars
|
|
})
|
|
const now = Date.now()
|
|
if (now - lastHiddenDropContradictionWarnAtMs < 60_000) {
|
|
return
|
|
}
|
|
lastHiddenDropContradictionWarnAtMs = now
|
|
console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', {
|
|
id,
|
|
droppedChars,
|
|
visible: visibleRendererPtys.has(id),
|
|
active: activeRendererPtys.has(id),
|
|
...readCurrentPtyRendererDeliveryDebugSnapshot()
|
|
})
|
|
}
|
|
|
|
function seedPtyRendererDeliveryPeaksFromCurrentState(): void {
|
|
let pendingChars = 0
|
|
let maxPendingCharsByPty = 0
|
|
for (const pending of pendingData.values()) {
|
|
const chars = pending.data.length
|
|
pendingChars += chars
|
|
maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars)
|
|
}
|
|
peakPendingChars = pendingChars
|
|
peakMaxPendingCharsByPty = maxPendingCharsByPty
|
|
peakRendererInFlightChars = rendererInFlightTotalChars
|
|
let maxRendererInFlightCharsByPty = 0
|
|
for (const accounting of rendererDeliveryAccountingByPty.values()) {
|
|
maxRendererInFlightCharsByPty = Math.max(
|
|
maxRendererInFlightCharsByPty,
|
|
accounting.sentChars - accounting.ackedChars
|
|
)
|
|
}
|
|
peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty
|
|
}
|
|
|
|
readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot
|
|
resetPtyRendererDeliveryDebugSnapshot = () => {
|
|
peakPendingChars = 0
|
|
peakMaxPendingCharsByPty = 0
|
|
peakRendererInFlightChars = 0
|
|
peakMaxRendererInFlightCharsByPty = 0
|
|
ackGatedFlushSkipCount = 0
|
|
pendingDroppedChars = 0
|
|
resetHiddenRendererPtyDeliveryDebugCounters()
|
|
seedPtyRendererDeliveryPeaksFromCurrentState()
|
|
}
|
|
resetRendererDeliveryAccountingForLifecycleReset = () => {
|
|
// Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it.
|
|
lastLifecycleResetClearedChars = rendererInFlightTotalChars
|
|
rendererLifecycleResetCount += 1
|
|
// Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged.
|
|
producerFlowControl.releaseAll()
|
|
clearDeliveryResyncProbe()
|
|
deliveryResyncUnansweredWarnLogged = false
|
|
for (const id of rendererDeliveryAccountingByPty.keys()) {
|
|
sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset')
|
|
}
|
|
rendererDeliveryAccountingByPty.clear()
|
|
rendererInFlightTotalChars = 0
|
|
clearPendingPtyData()
|
|
pendingOverflowMarkedPtys.clear()
|
|
rendererDeliveryRestoreNeededPtys.clear()
|
|
// Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate.
|
|
rendererPtyDispatcherReady = false
|
|
// Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it.
|
|
armDispatcherReadyWatchdog()
|
|
}
|
|
// Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition).
|
|
clearRendererDispatcherReadyWatchdog = clearDispatcherReadyWatchdog
|
|
|
|
function isLikelyInteractiveRedraw(data: string): boolean {
|
|
if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) {
|
|
return true
|
|
}
|
|
// Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (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 makePtyDataPayload(
|
|
id: string,
|
|
data: string,
|
|
startSeq: number | undefined,
|
|
containsBackgroundOutput: boolean | undefined,
|
|
rawLength = data.length,
|
|
transformed = false
|
|
): PtyDataPayload {
|
|
const payload: PtyDataPayload = { id, data }
|
|
if (typeof startSeq === 'number') {
|
|
payload.seq = startSeq + rawLength
|
|
}
|
|
if (typeof startSeq === 'number' || rawLength !== data.length || transformed) {
|
|
payload.rawLength = rawLength
|
|
}
|
|
if (transformed) {
|
|
payload.transformed = true
|
|
}
|
|
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 per-PTY (not global) reserve: keep one active pane 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 getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit
|
|
}
|
|
|
|
// Why max-merge cumulative totals: idempotent and reorder-tolerant — replayed/out-of-order ACKs can't double-credit and a lost ACK self-heals. Returns the newly acknowledged delta.
|
|
function applyCumulativeAck(id: string, processedChars: number): number {
|
|
const accounting = rendererDeliveryAccountingByPty.get(id)
|
|
if (!accounting) {
|
|
return 0
|
|
}
|
|
// Clamped to sentChars so a corrupt payload cannot drive in-flight negative.
|
|
const nextAckedChars = Math.min(
|
|
accounting.sentChars,
|
|
Math.max(accounting.ackedChars, processedChars)
|
|
)
|
|
const acknowledged = nextAckedChars - accounting.ackedChars
|
|
accounting.ackedChars = nextAckedChars
|
|
if (acknowledged > 0) {
|
|
accounting.lastAckAtMs = Date.now()
|
|
}
|
|
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
|
|
if (acknowledged > 0) {
|
|
sshOutputIntake?.settleProjectionPrefix(id, acknowledged)
|
|
}
|
|
return acknowledged
|
|
}
|
|
|
|
function schedulePendingDataAfterCreditReport(creditedAny: boolean): void {
|
|
if (creditedAny) {
|
|
pendingData.reactivateBlocked()
|
|
}
|
|
if (pendingData.size > 0 && !flushTimer) {
|
|
schedulePendingDataFlush(0)
|
|
}
|
|
}
|
|
|
|
function clearDeliveryResyncProbe(): void {
|
|
deliveryResyncOutstandingRequestId = null
|
|
if (deliveryResyncTimer) {
|
|
clearTimeout(deliveryResyncTimer)
|
|
deliveryResyncTimer = null
|
|
}
|
|
}
|
|
|
|
// Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess.
|
|
function requestDeliveryResyncForGatedPty(): void {
|
|
if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
deliveryResyncRequestSerial += 1
|
|
const requestId = deliveryResyncRequestSerial
|
|
deliveryResyncOutstandingRequestId = requestId
|
|
deliveryResyncTimer = setTimeout(() => {
|
|
if (deliveryResyncOutstandingRequestId !== requestId) {
|
|
return
|
|
}
|
|
clearDeliveryResyncProbe()
|
|
// Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe.
|
|
if (deliveryResyncUnansweredWarnLogged) {
|
|
return
|
|
}
|
|
deliveryResyncUnansweredWarnLogged = true
|
|
console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', {
|
|
msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs,
|
|
...readCurrentPtyRendererDeliveryDebugSnapshot()
|
|
})
|
|
}, PTY_DELIVERY_RESYNC_TIMEOUT_MS)
|
|
deliveryResyncTimer.unref?.()
|
|
mainWindow.webContents.send('pty:requestDeliveryResync', { requestId })
|
|
}
|
|
|
|
// Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot.
|
|
function writeOffLostRendererDelivery(
|
|
report: PtyRendererDeliveryStateReport
|
|
): PtyDeliveryWriteOff[] {
|
|
const writtenOff: PtyDeliveryWriteOff[] = []
|
|
for (const [id, accounting] of rendererDeliveryAccountingByPty) {
|
|
if (accounting.sentChars - accounting.ackedChars <= 0) {
|
|
continue
|
|
}
|
|
const received = report.receivedCharsByPty?.[id]
|
|
const receivedChars =
|
|
typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0
|
|
// Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt.
|
|
if (receivedChars > accounting.ackedChars) {
|
|
continue
|
|
}
|
|
const acknowledged = applyCumulativeAck(id, accounting.sentChars)
|
|
if (acknowledged <= 0) {
|
|
continue
|
|
}
|
|
tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged)
|
|
// Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore.
|
|
const pending = pendingData.get(id)
|
|
if (pending) {
|
|
if (pending.projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(
|
|
pending.projectionAdmissionIds,
|
|
'renderer-delivery-writeoff'
|
|
)
|
|
}
|
|
pendingDroppedChars += pending.data.length
|
|
deletePendingPtyData(id)
|
|
pendingOverflowMarkedPtys.delete(id)
|
|
updateProducerFlowControl(id)
|
|
}
|
|
const markerSeq = runtime?.getPtyOutputSequence(id)
|
|
writtenOff.push({
|
|
id,
|
|
...(typeof markerSeq === 'number' ? { markerSeq } : {}),
|
|
writtenOffChars: acknowledged
|
|
})
|
|
}
|
|
if (writtenOff.length > 0) {
|
|
clearDeliveryResyncProbe()
|
|
deliveryResyncUnansweredWarnLogged = false
|
|
mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', {
|
|
writtenOffPtyCount: writtenOff.length,
|
|
writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0)
|
|
})
|
|
console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', {
|
|
rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null,
|
|
msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs,
|
|
writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })),
|
|
...readCurrentPtyRendererDeliveryDebugSnapshot()
|
|
})
|
|
}
|
|
return writtenOff
|
|
}
|
|
|
|
function sendPtyDataToRenderer(
|
|
id: string,
|
|
payload: PtyDataPayload,
|
|
projectionAdmissionIds?: readonly string[]
|
|
): { sent: boolean; projectionsTransferred: boolean } {
|
|
const charCount = getPtyPayloadCharCount(payload)
|
|
const accounting = rendererDeliveryAccountingByPty.get(id)
|
|
const hadAccounting = accounting !== undefined
|
|
if (accounting) {
|
|
accounting.sentChars += charCount
|
|
accounting.lastSendAtMs = Date.now()
|
|
} else {
|
|
rendererDeliveryAccountingByPty.set(id, {
|
|
sentChars: charCount,
|
|
ackedChars: 0,
|
|
lastSendAtMs: Date.now(),
|
|
lastAckAtMs: null
|
|
})
|
|
}
|
|
rendererInFlightTotalChars += charCount
|
|
recordPtyRendererDeliveryPressure(id)
|
|
try {
|
|
mainWindow.webContents.send('pty:data', payload)
|
|
} catch (error) {
|
|
const current = rendererDeliveryAccountingByPty.get(id)
|
|
if (current) {
|
|
const inFlightBeforeRollback = current.sentChars - current.ackedChars
|
|
current.sentChars = Math.max(0, current.sentChars - charCount)
|
|
current.ackedChars = Math.min(current.ackedChars, current.sentChars)
|
|
const inFlightAfterRollback = current.sentChars - current.ackedChars
|
|
rendererInFlightTotalChars = Math.max(
|
|
0,
|
|
rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback)
|
|
)
|
|
if (!hadAccounting && current.sentChars === 0) {
|
|
rendererDeliveryAccountingByPty.delete(id)
|
|
}
|
|
}
|
|
rendererDeliveryRestoreNeededPtys.add(id)
|
|
if (projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed')
|
|
}
|
|
mainDeliveryBreadcrumbs.record('pty-data-send-failed', {
|
|
id: redactPtyIdForDiagnostics(id),
|
|
chars: charCount
|
|
})
|
|
console.error('[pty] renderer data send failed; payload will not be retried', error)
|
|
return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined }
|
|
}
|
|
let projectionsTransferred = false
|
|
if (projectionAdmissionIds) {
|
|
try {
|
|
sshOutputIntake?.publishProjectionPrefix(
|
|
projectionAdmissionIds,
|
|
payload.data.length,
|
|
charCount
|
|
)
|
|
} catch {
|
|
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed')
|
|
projectionsTransferred = true
|
|
}
|
|
}
|
|
if (rendererDeliveryRestoreNeededPtys.has(id)) {
|
|
try {
|
|
sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id))
|
|
rendererDeliveryRestoreNeededPtys.delete(id)
|
|
} catch (error) {
|
|
console.error(
|
|
'[pty] renderer delivery-heal marker send failed; restore remains pending',
|
|
error
|
|
)
|
|
}
|
|
}
|
|
return { sent: true, projectionsTransferred }
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Why out-of-band (not pty:data): an in-band empty chunk is indistinguishable from one fully consumed by renderer-side OSC-9999 stripping, which spuriously restored visible panes.
|
|
function sendModelRestoreNeededMarker(
|
|
id: string,
|
|
reason: PtyModelRestoreReason,
|
|
markerSeq: number | undefined
|
|
): void {
|
|
if (mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
mainWindow.webContents.send('pty:modelRestoreNeeded', {
|
|
id,
|
|
reason,
|
|
...(typeof markerSeq === 'number' ? { markerSeq } : {})
|
|
})
|
|
}
|
|
|
|
const pendingDataDropWarnedPtys = new Set<string>()
|
|
|
|
// Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel.
|
|
const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096
|
|
|
|
// Why carve out queries: a bulk drop must not swallow reply-eliciting probes (DSR/CPR, DA1/DA2, DECRQM, OSC 10/11) the program blocks on; snapshot heals content so replies can't double-fire.
|
|
function extractDroppedPtyQueryBytes(data: string): string {
|
|
if (!data.includes('\x1b')) {
|
|
return ''
|
|
}
|
|
const extracted = extractHiddenStartupRendererQueryData(data, '')
|
|
return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData
|
|
}
|
|
|
|
function scanDroppedMode2031Data(
|
|
data: string,
|
|
previous: Mode2031ReplyScanState
|
|
): { data: string; state: Mode2031ReplyScanState } {
|
|
const result = scanMode2031ReplyDecision(previous, data)
|
|
const decisionData =
|
|
result.decision === 'subscribed'
|
|
? '\x1b[?2031h'
|
|
: result.decision === 'unsubscribed'
|
|
? '\x1b[?2031l'
|
|
: ''
|
|
return { data: decisionData, state: result.state }
|
|
}
|
|
|
|
function getDroppedMode2031RendererData(pending: PendingPtyData): string {
|
|
const state = pending.droppedMode2031ScanState
|
|
if (!state) {
|
|
return pending.droppedMode2031Data ?? ''
|
|
}
|
|
const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : ''
|
|
return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail
|
|
}
|
|
|
|
function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData {
|
|
const capChars = pendingDataCapChars()
|
|
if (pending.droppedOutput === true || pending.data.length <= capChars) {
|
|
return pending
|
|
}
|
|
if (!pendingDataDropWarnedPtys.has(id)) {
|
|
pendingDataDropWarnedPtys.add(id)
|
|
console.error(
|
|
`[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot`
|
|
)
|
|
// Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths.
|
|
recordCrashBreadcrumb('terminal_pending_output_dropped', {
|
|
droppedChars: pending.data.length,
|
|
capChars
|
|
})
|
|
}
|
|
if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) {
|
|
pendingOverflowMarkedPtys.add(id)
|
|
}
|
|
pendingDroppedChars += pending.data.length
|
|
if (pending.projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap')
|
|
}
|
|
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
|
|
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
|
|
return {
|
|
data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS),
|
|
droppedOutput: true,
|
|
droppedMode2031Data: mode2031.data,
|
|
droppedMode2031ScanState: mode2031.state
|
|
}
|
|
}
|
|
|
|
function updatePendingProjectionAdmissions(
|
|
pending: PendingPtyData,
|
|
state: PendingProjectionAdmissions
|
|
): void {
|
|
delete pending.projectionAdmissionIds
|
|
delete pending.projectionAdmissionsTransferred
|
|
if (state.projectionAdmissionIds) {
|
|
pending.projectionAdmissionIds = state.projectionAdmissionIds
|
|
}
|
|
if (state.projectionAdmissionsTransferred) {
|
|
pending.projectionAdmissionsTransferred = true
|
|
}
|
|
}
|
|
|
|
function compactPendingProjectionState(
|
|
pending: PendingProjectionAdmissions,
|
|
projectionSemanticsId?: string
|
|
): PendingProjectionAdmissions {
|
|
const options = pendingProjectionAdmissionOptions()
|
|
const compacted = compactPendingProjectionAdmissions(pending, options)
|
|
return projectionSemanticsId
|
|
? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options)
|
|
: compacted
|
|
}
|
|
|
|
function pendingProjectionAdmissionOptions() {
|
|
return {
|
|
isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false,
|
|
transfer: (ids: readonly string[], reason: string) =>
|
|
sshOutputIntake?.transferProjections(ids, reason)
|
|
}
|
|
}
|
|
|
|
function appendPendingPtyData(
|
|
id: string,
|
|
existing: PendingPtyData | undefined,
|
|
data: string,
|
|
startSeq: number | undefined,
|
|
preservesSeq: boolean,
|
|
containsBackgroundOutput: boolean,
|
|
rawLength = data.length,
|
|
transformed = false,
|
|
projectionSemanticsId?: string
|
|
): PendingPtyData {
|
|
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
|
|
if (existing?.droppedOutput === true) {
|
|
if (projectionSemanticsId) {
|
|
sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap')
|
|
}
|
|
const mode2031 = scanDroppedMode2031Data(
|
|
data,
|
|
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
|
|
)
|
|
const remainingQueryCapacity = Math.max(
|
|
0,
|
|
DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length
|
|
)
|
|
const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity)
|
|
return {
|
|
...existing,
|
|
data: existing.data + salvaged,
|
|
droppedMode2031Data: mode2031.data || existing.droppedMode2031Data,
|
|
droppedMode2031ScanState: mode2031.state
|
|
}
|
|
}
|
|
const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId)
|
|
const nextContainsBackgroundOutput =
|
|
existing?.containsBackgroundOutput === true || containsBackgroundOutput
|
|
if (!existing) {
|
|
const pending: PendingPtyData = {
|
|
data,
|
|
...(typeof startSeq === 'number' ? { startSeq } : {}),
|
|
...(rawLength !== data.length ? { rawLength } : {}),
|
|
...(transformed ? { transformed: true } : {}),
|
|
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
|
}
|
|
updatePendingProjectionAdmissions(pending, projectionState)
|
|
return dropOversizedPendingPtyData(id, pending)
|
|
}
|
|
const existingRawLength = existing.rawLength ?? existing.data.length
|
|
const next: PendingPtyData = {
|
|
data: existing.data + data,
|
|
...(!preservesSeq || existing.transformed || transformed
|
|
? { rawLength: existingRawLength + rawLength, transformed: true as const }
|
|
: {}),
|
|
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
|
}
|
|
updatePendingProjectionAdmissions(next, projectionState)
|
|
if (typeof existing.startSeq === 'number') {
|
|
next.startSeq = existing.startSeq
|
|
}
|
|
return dropOversizedPendingPtyData(id, next)
|
|
}
|
|
|
|
function schedulePendingDataFlush(delayMs: number): void {
|
|
if (flushTimer) {
|
|
return
|
|
}
|
|
flushTimer = setTimeout(flushPendingData, delayMs)
|
|
}
|
|
|
|
function invalidatePendingPtyDrainClassification(id?: string, schedule = true): void {
|
|
const invalidated =
|
|
typeof id === 'string' ? pendingData.invalidate(id) : pendingData.invalidateAll()
|
|
if (invalidated && schedule && !flushTimer) {
|
|
schedulePendingDataFlush(0)
|
|
}
|
|
}
|
|
invalidatePendingPtyDrainPriority = invalidatePendingPtyDrainClassification
|
|
invalidatePendingPtyDrainPolicy = invalidatePendingPtyDrainClassification
|
|
|
|
function clearDispatcherReadyWatchdog(): void {
|
|
if (dispatcherReadyWatchdogTimer) {
|
|
clearTimeout(dispatcherReadyWatchdogTimer)
|
|
dispatcherReadyWatchdogTimer = null
|
|
}
|
|
}
|
|
|
|
function armDispatcherReadyWatchdog(): void {
|
|
clearDispatcherReadyWatchdog()
|
|
if (mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
// Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive.
|
|
dispatcherReadyWatchdogTimer = setTimeout(() => {
|
|
dispatcherReadyWatchdogTimer = null
|
|
if (rendererPtyDispatcherReady || mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
rendererPtyDispatcherReady = true
|
|
rendererDispatcherReadyForcedCount += 1
|
|
pendingData.reactivateBlocked()
|
|
schedulePendingDataFlush(0)
|
|
}, PTY_DISPATCHER_READY_WATCHDOG_MS)
|
|
dispatcherReadyWatchdogTimer.unref?.()
|
|
}
|
|
|
|
function flushPendingData(): void {
|
|
flushTimer = null
|
|
if (mainWindow.isDestroyed()) {
|
|
// Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge.
|
|
producerFlowControl.releaseAll()
|
|
clearDeliveryResyncProbe()
|
|
clearPendingPtyData()
|
|
pendingOverflowMarkedPtys.clear()
|
|
rendererDeliveryAccountingByPty.clear()
|
|
rendererInFlightTotalChars = 0
|
|
clearDispatcherReadyWatchdog()
|
|
return
|
|
}
|
|
// Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness.
|
|
const settings = getSettings?.()
|
|
let writes = 0
|
|
let sendFailed = false
|
|
const round = pendingData.beginRound()
|
|
let creditReleasedDuringFlush = false
|
|
pendingDataFlushActive = true
|
|
pendingDataCreditReleasedDuringFlush = false
|
|
try {
|
|
while (writes < PTY_BATCH_FLUSH_MAX_WRITES) {
|
|
const selection = pendingData.takeNext(round)
|
|
if (!selection) {
|
|
break
|
|
}
|
|
const { id, pending } = selection
|
|
// Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery.
|
|
if (shouldDropHiddenRendererPtyData(id, settings)) {
|
|
pendingData.remove(selection)
|
|
pendingOverflowMarkedPtys.delete(id)
|
|
updateProducerFlowControl(id)
|
|
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
|
|
if (pending.projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
|
}
|
|
warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length)
|
|
if (drop.shouldEmitRestoreMarker) {
|
|
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
|
|
}
|
|
continue
|
|
}
|
|
if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) {
|
|
pendingData.block(selection)
|
|
continue
|
|
}
|
|
if (pending.droppedOutput === true) {
|
|
pendingData.remove(selection)
|
|
updateProducerFlowControl(id)
|
|
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
|
|
if (
|
|
!sendPtyDataToRenderer(
|
|
id,
|
|
{
|
|
id,
|
|
data: pending.data + getDroppedMode2031RendererData(pending),
|
|
droppedOutput: true
|
|
},
|
|
pending.projectionAdmissionIds
|
|
).sent
|
|
) {
|
|
sendFailed = true
|
|
break
|
|
}
|
|
writes++
|
|
continue
|
|
}
|
|
const { data } = pending
|
|
const indivisible = pending.transformed === true
|
|
const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
|
|
const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
|
|
let nextPending: PendingPtyData | undefined
|
|
if (remaining) {
|
|
nextPending = { data: remaining }
|
|
if (typeof pending.startSeq === 'number') {
|
|
nextPending.startSeq = pending.startSeq + chunk.length
|
|
}
|
|
if (pending.containsBackgroundOutput === true) {
|
|
nextPending.containsBackgroundOutput = true
|
|
}
|
|
if (pending.projectionAdmissionIds) {
|
|
nextPending.projectionAdmissionIds = pending.projectionAdmissionIds
|
|
}
|
|
if (pending.projectionAdmissionsTransferred) {
|
|
nextPending.projectionAdmissionsTransferred = true
|
|
}
|
|
pendingData.replaceWithRemainder(selection, nextPending)
|
|
} else {
|
|
pendingData.remove(selection)
|
|
pendingOverflowMarkedPtys.delete(id)
|
|
}
|
|
updateProducerFlowControl(id)
|
|
const delivery = sendPtyDataToRenderer(
|
|
id,
|
|
makePtyDataPayload(
|
|
id,
|
|
chunk,
|
|
pending.startSeq,
|
|
pending.containsBackgroundOutput,
|
|
pending.rawLength,
|
|
pending.transformed
|
|
),
|
|
pending.projectionAdmissionIds
|
|
)
|
|
if (nextPending) {
|
|
updatePendingProjectionAdmissions(
|
|
nextPending,
|
|
propagatePendingProjectionRemainder(
|
|
nextPending,
|
|
delivery,
|
|
pendingProjectionAdmissionOptions()
|
|
)
|
|
)
|
|
}
|
|
if (!delivery.sent) {
|
|
sendFailed = true
|
|
break
|
|
}
|
|
writes++
|
|
}
|
|
} finally {
|
|
pendingDataFlushActive = false
|
|
creditReleasedDuringFlush = pendingDataCreditReleasedDuringFlush
|
|
pendingDataCreditReleasedDuringFlush = false
|
|
pendingData.endRound(round)
|
|
}
|
|
if (rendererPtyDispatcherReady && pendingData.size > 0 && writes === 0 && !sendFailed) {
|
|
ackGatedFlushSkipCount++
|
|
}
|
|
if (sendFailed && pendingData.size > 0) {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS)
|
|
return
|
|
}
|
|
if (pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) {
|
|
// Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush.
|
|
schedulePendingDataFlush(writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0)
|
|
}
|
|
}
|
|
|
|
const clearFlushTimerIfIdle = (): void => {
|
|
if (pendingData.size > 0 || flushTimer === null) {
|
|
return
|
|
}
|
|
clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
|
|
const syntheticKillExitPtyIds = new Map<string, NodeJS.Timeout>()
|
|
const reversibleStopOwnersByPtyId = new Map<string, number>()
|
|
|
|
function rememberSyntheticKillExit(id: string): void {
|
|
const existing = syntheticKillExitPtyIds.get(id)
|
|
if (existing) {
|
|
clearTimeout(existing)
|
|
}
|
|
// Why a timed window: providers may report the real exit after kill completes; 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 preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null {
|
|
if (mainWindow.isDestroyed()) {
|
|
sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed')
|
|
return () => {}
|
|
}
|
|
if (rendererExitingPtyIds.has(payload.id)) {
|
|
return null
|
|
}
|
|
rendererExitingPtyIds.add(payload.id)
|
|
let released = false
|
|
const release = (): void => {
|
|
if (released) {
|
|
return
|
|
}
|
|
released = true
|
|
rendererExitingPtyIds.delete(payload.id)
|
|
}
|
|
try {
|
|
if (!rendererCreditBeforeExitByPty.has(payload.id)) {
|
|
rendererCreditBeforeExitByPty.set(
|
|
payload.id,
|
|
getRendererInFlightCharsForPty(payload.id) > 0
|
|
)
|
|
}
|
|
// Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost.
|
|
const remaining = pendingData.delete(payload.id)
|
|
clearFlushTimerIfIdle()
|
|
if (remaining) {
|
|
if (remaining.droppedOutput === true) {
|
|
// Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped.
|
|
sendPtyDataToRenderer(
|
|
payload.id,
|
|
{
|
|
id: payload.id,
|
|
data: remaining.data,
|
|
droppedOutput: true
|
|
},
|
|
remaining.projectionAdmissionIds
|
|
)
|
|
} else {
|
|
sendPtyDataToRenderer(
|
|
payload.id,
|
|
makePtyDataPayload(
|
|
payload.id,
|
|
remaining.data,
|
|
remaining.startSeq,
|
|
remaining.containsBackgroundOutput,
|
|
remaining.rawLength,
|
|
remaining.transformed
|
|
),
|
|
remaining.projectionAdmissionIds
|
|
)
|
|
}
|
|
}
|
|
return release
|
|
} catch (error) {
|
|
release()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function finalizePtyExitForRenderer(payload: { id: string; code: number }): void {
|
|
if (mainWindow.isDestroyed()) {
|
|
rendererCreditBeforeExitByPty.delete(payload.id)
|
|
return
|
|
}
|
|
const hadReleasableRendererCredit =
|
|
rendererCreditBeforeExitByPty.get(payload.id) ??
|
|
getRendererInFlightCharsForPty(payload.id) > 0
|
|
rendererCreditBeforeExitByPty.delete(payload.id)
|
|
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
|
|
producerFlowControl.release(payload.id)
|
|
sourceCreditPendingPtys.delete(payload.id)
|
|
pendingOverflowMarkedPtys.delete(payload.id)
|
|
rendererDeliveryRestoreNeededPtys.delete(payload.id)
|
|
lastInputAtByPty.delete(payload.id)
|
|
interactiveOutputCharsByPty.delete(payload.id)
|
|
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
|
|
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
|
|
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
|
|
rendererDeliveryAccountingByPty.delete(payload.id)
|
|
if (hadReleasableRendererCredit) {
|
|
if (pendingDataFlushActive) {
|
|
// Why: let the open round coalesce this wake into its one post-round continuation.
|
|
const reactivatedBlocked = pendingData.reactivateBlocked()
|
|
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
|
|
} else {
|
|
schedulePendingDataAfterCreditReport(true)
|
|
}
|
|
}
|
|
mainWindow.webContents.send('pty:exit', {
|
|
...payload,
|
|
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
|
|
})
|
|
}
|
|
|
|
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
|
const release = preparePtyExitForRenderer(payload)
|
|
if (!release) {
|
|
return
|
|
}
|
|
try {
|
|
sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit')
|
|
finalizePtyExitForRenderer(payload)
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
function sendPtySpawnedToRenderer(id: string): void {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('pty:spawned', { id })
|
|
}
|
|
}
|
|
|
|
function acceptPtyDataForRenderer(
|
|
payload: {
|
|
id: string
|
|
data: string
|
|
sequenceChars?: number
|
|
transformed?: boolean
|
|
},
|
|
outputSeq: number | undefined,
|
|
projection?: LegacySshProjectionSemantics
|
|
): void {
|
|
const rawLength = payload.sequenceChars ?? payload.data.length
|
|
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
|
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
|
const projectionId = projection?.identity.projectionSemanticsId
|
|
if (mainWindow.isDestroyed()) {
|
|
if (projectionId) {
|
|
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
|
|
}
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
producerFlowControl.releaseAll()
|
|
clearDeliveryResyncProbe()
|
|
clearPendingPtyData()
|
|
pendingOverflowMarkedPtys.clear()
|
|
rendererDeliveryAccountingByPty.clear()
|
|
rendererInFlightTotalChars = 0
|
|
clearDispatcherReadyWatchdog()
|
|
return
|
|
}
|
|
if (rendererExitingPtyIds.has(payload.id)) {
|
|
if (projectionId) {
|
|
sshOutputIntake?.transferProjections([projectionId], 'pty-exiting')
|
|
}
|
|
return
|
|
}
|
|
if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) {
|
|
if (projectionId) {
|
|
sshOutputIntake?.transferProjections([projectionId], 'hidden-drop')
|
|
}
|
|
const droppedChars = projection ? rawLength : payload.data.length
|
|
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
|
|
warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars)
|
|
if (drop.shouldEmitRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
|
}
|
|
return
|
|
}
|
|
if (payload.data.length === 0 && !payload.transformed) {
|
|
if (projectionId) {
|
|
sshOutputIntake?.transferProjections([projectionId], 'empty-projection')
|
|
}
|
|
return
|
|
}
|
|
const containsBackgroundOutput =
|
|
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
|
if (containsBackgroundOutput) {
|
|
markHiddenRendererResizeOutputDelivered(payload.id)
|
|
}
|
|
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
|
if (projection?.desktopSpan) {
|
|
sourceCreditPendingPtys.add(payload.id)
|
|
}
|
|
const pending = appendPendingPtyData(
|
|
payload.id,
|
|
pendingData.get(payload.id),
|
|
payload.data,
|
|
startSeq,
|
|
preservesSeq,
|
|
containsBackgroundOutput,
|
|
rawLength,
|
|
payload.transformed === true,
|
|
projectionId
|
|
)
|
|
const shouldEmitPendingCapRestoreMarker =
|
|
pending.droppedOutput === true &&
|
|
!overflowMarkedBeforeAppend &&
|
|
pendingOverflowMarkedPtys.has(payload.id)
|
|
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
|
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
|
payload.id,
|
|
nextData,
|
|
performance.now()
|
|
)
|
|
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
|
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
|
|
setPendingPtyData(payload.id, pending)
|
|
if (shouldEmitPendingCapRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
|
}
|
|
updateProducerFlowControl(payload.id)
|
|
requestDeliveryResyncForGatedPty()
|
|
return
|
|
}
|
|
deletePendingPtyData(payload.id)
|
|
clearFlushTimerIfIdle()
|
|
if (shouldEmitPendingCapRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
|
}
|
|
pendingOverflowMarkedPtys.delete(payload.id)
|
|
try {
|
|
sendPtyDataToRenderer(
|
|
payload.id,
|
|
{
|
|
id: payload.id,
|
|
data: nextData,
|
|
...(typeof pending.startSeq === 'number'
|
|
? {
|
|
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
|
|
rawLength: pending.rawLength ?? nextData.length
|
|
}
|
|
: {}),
|
|
...(pending.transformed ? { transformed: true } : {}),
|
|
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
|
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
|
},
|
|
pending.projectionAdmissionIds
|
|
)
|
|
} finally {
|
|
updateProducerFlowControl(payload.id)
|
|
}
|
|
return
|
|
}
|
|
setPendingPtyData(payload.id, pending)
|
|
if (shouldEmitPendingCapRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
|
}
|
|
updateProducerFlowControl(payload.id)
|
|
if (
|
|
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
|
) {
|
|
requestDeliveryResyncForGatedPty()
|
|
}
|
|
if (!flushTimer) {
|
|
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
sshOutputIntakeCleanup?.()
|
|
sshOutputIntake = new SshPtyOutputIntake({
|
|
getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0,
|
|
acceptModel: (event, projection) => {
|
|
if (!runtime) {
|
|
throw new Error('SSH PTY output requires the main terminal model')
|
|
}
|
|
return runtime.acceptPtyDataBounded(
|
|
event.id,
|
|
event.data,
|
|
Date.now(),
|
|
event.rawLength,
|
|
event.transformed,
|
|
projection.desktopSpan ? [projection.desktopSpan] : undefined
|
|
)
|
|
},
|
|
project: (event, projection) =>
|
|
acceptPtyDataForRenderer(
|
|
{
|
|
id: event.id,
|
|
data: event.data,
|
|
sequenceChars: event.rawLength,
|
|
transformed: event.transformed
|
|
},
|
|
projection.identity.sequenceEnd,
|
|
projection
|
|
),
|
|
prepareExit: (event) => {
|
|
const release = preparePtyExitForRenderer(event)
|
|
if (!release) {
|
|
throw new Error('pty_renderer_exit_in_progress')
|
|
}
|
|
return release
|
|
},
|
|
finalizeExit: (event) => {
|
|
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
|
|
finalizePtyExitForRenderer(event)
|
|
},
|
|
pauseProvider: (generation, id) => {
|
|
const provider = sshProvidersByGeneration.get(generation) as
|
|
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
|
|
| undefined
|
|
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
|
|
return false
|
|
}
|
|
provider.pauseProducer?.(id)
|
|
return true
|
|
},
|
|
resumeProvider: (generation, id) =>
|
|
sshProvidersByGeneration.get(generation)?.resumeProducer?.(id),
|
|
closeProvider: (generation, reason) => {
|
|
const provider = sshProvidersByGeneration.get(generation)
|
|
;(
|
|
provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined
|
|
)?.closeOutputIntake?.(reason)
|
|
},
|
|
resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id),
|
|
onGenerationClosed: (providerGeneration) => {
|
|
for (const id of pendingData.keys()) {
|
|
const pending = pendingData.get(id)
|
|
if (
|
|
pending?.projectionAdmissionIds &&
|
|
sshOutputIntake?.hasProjectionFromGeneration(
|
|
pending.projectionAdmissionIds,
|
|
providerGeneration
|
|
)
|
|
) {
|
|
pendingData.delete(id)
|
|
updateProducerFlowControl(id)
|
|
pendingOverflowMarkedPtys.delete(id)
|
|
}
|
|
}
|
|
sshProvidersByGeneration.delete(providerGeneration)
|
|
},
|
|
publishSourceAck: publishSshPtySourceAck,
|
|
cancelSourceDelivery: cancelSshPtySourceDelivery
|
|
})
|
|
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(
|
|
sshOutputIntake.getRemoteSourceRangeConsumerHooks()
|
|
)
|
|
const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake)
|
|
sshOutputIntakeCleanup = () => {
|
|
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null)
|
|
cleanupSshOutputIntakeRegistry()
|
|
}
|
|
|
|
async function shutdownProviderAndDetectExit(
|
|
provider: IPtyProvider,
|
|
id: string,
|
|
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
|
): Promise<boolean> {
|
|
let providerExitObserved = false
|
|
const expectedIncarnationId = ptyIncarnationById.get(id)
|
|
const unsubscribe = provider.onExit((payload) => {
|
|
if (
|
|
payload.id === id &&
|
|
(!expectedIncarnationId || payload.incarnationId === expectedIncarnationId)
|
|
) {
|
|
providerExitObserved = true
|
|
}
|
|
})
|
|
try {
|
|
await provider.shutdown(id, opts)
|
|
} finally {
|
|
unsubscribe()
|
|
}
|
|
return providerExitObserved
|
|
}
|
|
|
|
// Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration.
|
|
const bindProviderListeners = (): void => {
|
|
localDataUnsub?.()
|
|
localExitUnsub?.()
|
|
localBackgroundStreamUnsub?.()
|
|
localWriteUnavailableUnsub?.()
|
|
|
|
// Why: a daemon death takes down every session at once. The provider signals
|
|
// each affected pane here so background panes remount + re-attach too, not
|
|
// just the pane whose write happened to detect the dead endpoint (STA-2373).
|
|
localWriteUnavailableUnsub =
|
|
localProvider.onWriteUnavailable?.((payload) => {
|
|
if (
|
|
mainWindow.isDestroyed() ||
|
|
(typeof mainWindow.webContents.isDestroyed === 'function' &&
|
|
mainWindow.webContents.isDestroyed())
|
|
) {
|
|
return
|
|
}
|
|
mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id })
|
|
}) ?? null
|
|
|
|
// Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot.
|
|
localBackgroundStreamUnsub =
|
|
localProvider.onBackgroundStreamEvent?.((payload) => {
|
|
if (payload.kind === 'backgroundMarker') {
|
|
runtime?.setPtyTransientFactDelegation(
|
|
payload.id,
|
|
payload.background,
|
|
payload.scanSeedAnsi,
|
|
payload.mode2031PendingSubscribe
|
|
)
|
|
return
|
|
}
|
|
if (payload.kind === 'dataGap') {
|
|
providerSnapshotRequiredPtys.add(payload.id)
|
|
runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars)
|
|
sendModelRestoreNeededMarker(
|
|
payload.id,
|
|
'hidden-drop',
|
|
runtime?.getPtyOutputSequence(payload.id)
|
|
)
|
|
return
|
|
}
|
|
runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact)
|
|
}) ?? null
|
|
|
|
// Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty.
|
|
const isLocalProvider = localProvider instanceof LocalPtyProvider
|
|
|
|
localDataUnsub = localProvider.onData((payload) => {
|
|
const rawLength = payload.sequenceChars ?? payload.data.length
|
|
const outputSeq = isLocalProvider
|
|
? runtime?.getPtyOutputSequence(payload.id)
|
|
: runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
|
|
acceptPtyDataForRenderer(payload, outputSeq)
|
|
})
|
|
localExitUnsub = localProvider.onExit((payload) => {
|
|
if (!isCurrentPtyExit(payload)) {
|
|
return
|
|
}
|
|
if (consumeSyntheticKillExit(payload.id)) {
|
|
return
|
|
}
|
|
if (!isLocalProvider) {
|
|
clearProviderPtyState(payload.id)
|
|
ptyOwnership.delete(payload.id)
|
|
markClaudePtyExited(payload.id)
|
|
runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
|
|
}
|
|
sendPtyExitToRenderer(payload)
|
|
})
|
|
}
|
|
|
|
bindProviderListeners()
|
|
rebindProviderListeners = bindProviderListeners
|
|
|
|
// Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning.
|
|
type SerializeResult = {
|
|
data: string
|
|
cols: number
|
|
rows: number
|
|
seq?: 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
|
|
seq?: 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
|
|
seq?: number
|
|
lastTitle?: string
|
|
} = {
|
|
data: snapshot.data,
|
|
cols: snapshot.cols,
|
|
rows: snapshot.rows
|
|
}
|
|
if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) {
|
|
result.seq = snapshot.seq
|
|
}
|
|
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)
|
|
})
|
|
}
|
|
|
|
// Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks.
|
|
clearRendererGateResetHandlers()
|
|
const resetRendererPtyDeliveryGateState = (): void => {
|
|
const gateDebug = getHiddenRendererPtyDeliveryDebug()
|
|
resetRendererScopedHiddenPtyDeliveryState()
|
|
if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) {
|
|
invalidatePendingPtyDrainPolicy()
|
|
}
|
|
// Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones.
|
|
resyncBackgroundedDeliveriesAfterGateReset()
|
|
}
|
|
rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState
|
|
rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState
|
|
rendererGateResetWebContents = mainWindow.webContents
|
|
mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler)
|
|
mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler)
|
|
|
|
// Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them.
|
|
clearDidFinishLoadHandler()
|
|
if (localProvider instanceof LocalPtyProvider) {
|
|
const lp = localProvider
|
|
didFinishLoadHandler = () => {
|
|
// Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787).
|
|
const generation = lp.advanceGeneration()
|
|
if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) {
|
|
return
|
|
}
|
|
// Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it.
|
|
lp.killOrphanedPtys(generation - 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 resolvePtySpawnStartupCwd = (
|
|
worktreeId: string | undefined,
|
|
cwd: string | undefined,
|
|
missingDirFallback?: TerminalStartupCwdMissingDirFallback
|
|
): string | undefined =>
|
|
resolveTerminalStartupCwdForWorkspace({
|
|
workspaceId: worktreeId,
|
|
requestedCwd: cwd,
|
|
missingDirFallback,
|
|
resolveFolderWorkspacePath: (folderWorkspaceId) =>
|
|
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
|
|
})
|
|
|
|
const localStartupCwdDirectoryExists = (path: string): boolean => {
|
|
// Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation.
|
|
if (isWslUncPath(path)) {
|
|
return true
|
|
}
|
|
try {
|
|
return statSync(path).isDirectory()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const prepareCodexResumeHome = (args: {
|
|
connectionId?: string | null
|
|
launchAgent?: TuiAgent
|
|
providerSession?: AgentProviderSessionMetadata
|
|
target: CodexAccountSelectionTarget
|
|
launchEnv?: NodeJS.ProcessEnv
|
|
workspacePath?: string
|
|
}): {
|
|
providerSession: AgentProviderSessionMetadata
|
|
preparation: Promise<CodexSessionResumePreparation | null>
|
|
} | null => {
|
|
if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) {
|
|
return null
|
|
}
|
|
const providerSession = normalizeAgentProviderSession(args.providerSession)
|
|
if (!providerSession) {
|
|
return null
|
|
}
|
|
return {
|
|
providerSession,
|
|
preparation: options.prepareCodexSessionResume({
|
|
providerSession,
|
|
target: args.target,
|
|
launchEnv: args.launchEnv,
|
|
workspacePath: args.workspacePath
|
|
})
|
|
}
|
|
}
|
|
|
|
type CodexResumeLaunch = {
|
|
codexResumeHome: { codexHomePath: string } | null
|
|
command: string | undefined
|
|
notifyResumeUnavailable: boolean
|
|
droppedResumeArgv: boolean
|
|
providerSession: AgentProviderSessionMetadata | null
|
|
}
|
|
|
|
/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await:
|
|
* an extra tick reorders the pane-spawn reservation races this handler arbitrates. */
|
|
const noCodexResumeLaunch = (command: string | undefined): CodexResumeLaunch => ({
|
|
codexResumeHome: null,
|
|
command,
|
|
notifyResumeUnavailable: false,
|
|
droppedResumeArgv: false,
|
|
providerSession: null
|
|
})
|
|
|
|
/** The command a Codex launch actually runs: unchanged when provenance is verified,
|
|
* stripped of `resume <id>` when it is not. */
|
|
const resolveCodexResumeLaunch = (
|
|
command: string | undefined,
|
|
preparation: NonNullable<ReturnType<typeof prepareCodexResumeHome>>
|
|
): Promise<CodexResumeLaunch> =>
|
|
preparation.preparation.then((prepared) => {
|
|
const providerSession = preparation.providerSession
|
|
if (prepared?.outcome !== 'fresh') {
|
|
return {
|
|
codexResumeHome: prepared ?? null,
|
|
command,
|
|
notifyResumeUnavailable: false,
|
|
droppedResumeArgv: false,
|
|
providerSession
|
|
}
|
|
}
|
|
const dropped = dropUnverifiedCodexResumeArgv({
|
|
command,
|
|
providerSession,
|
|
claimedCodexProvenance: prepared.claimedCodexProvenance
|
|
})
|
|
return {
|
|
codexResumeHome: null,
|
|
command: dropped.command,
|
|
// Why: staying silent only makes sense for metadata that positively belongs to
|
|
// another agent; a resume with no transcript path at all still owes the user a notice.
|
|
notifyResumeUnavailable:
|
|
dropped.droppedResumeArgv &&
|
|
(prepared.claimedCodexProvenance || !providerSession.transcriptPath),
|
|
droppedResumeArgv: dropped.droppedResumeArgv,
|
|
providerSession
|
|
}
|
|
})
|
|
|
|
/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command
|
|
* and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */
|
|
const stripSequencedStartupResumeArgv = <T extends Record<string, string> | undefined>(
|
|
env: T,
|
|
launch: CodexResumeLaunch
|
|
): T => {
|
|
const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]
|
|
if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) {
|
|
return env
|
|
}
|
|
const drop = dropAgentResumeArgvFromCommand({
|
|
command: sequenced,
|
|
agent: 'codex',
|
|
providerSession: launch.providerSession
|
|
})
|
|
return drop.status === 'dropped'
|
|
? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command }
|
|
: env
|
|
}
|
|
|
|
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
|
|
runtime?.setPtyController({
|
|
spawn: async (args) => {
|
|
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
|
|
if (startupPromise) {
|
|
await startupPromise
|
|
}
|
|
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
|
const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
|
|
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 carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's 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 isDaemonHostSpawn =
|
|
!args.connectionId &&
|
|
!(provider instanceof LocalPtyProvider) &&
|
|
!routesFreshSpawnsToLocalProvider(provider)
|
|
const callerRequestedSessionId = args.sessionId?.trim()
|
|
const requestedSessionId =
|
|
callerRequestedSessionId ??
|
|
(isDaemonHostSpawn && args.agentSessionCreateOperationId
|
|
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
|
|
: undefined)
|
|
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 = callerRequestedSessionId === undefined && isDaemonHostSpawn
|
|
const expectedWslDistro = !args.connectionId
|
|
? (resolveWslSessionContext({
|
|
cwd,
|
|
sessionId,
|
|
shellOverride: terminalRuntimeOptions.shellOverride,
|
|
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
|
})?.distro ?? null)
|
|
: null
|
|
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
|
daemonShellOverride,
|
|
cwd,
|
|
expectedWslDistro
|
|
)
|
|
const codexResumePreparation = prepareCodexResumeHome({
|
|
connectionId: args.connectionId,
|
|
launchAgent: args.launchAgent,
|
|
providerSession: args.resumeProviderSession,
|
|
target: codexSelectionTarget,
|
|
launchEnv: args.env,
|
|
workspacePath: cwd
|
|
})
|
|
const codexResumeLaunch = codexResumePreparation
|
|
? await resolveCodexResumeLaunch(args.command, codexResumePreparation)
|
|
: noCodexResumeLaunch(args.command)
|
|
const codexResumeHome = codexResumeLaunch.codexResumeHome
|
|
// Why: the drop still applies here, but this controller's result has no field for
|
|
// notifyResumeUnavailable — runtime/relay panes start fresh without the notice.
|
|
const launchCommand = codexResumeLaunch.command
|
|
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 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' ||
|
|
!isValidTerminalTabId(args.tabId) ||
|
|
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
|
|
env = stripSequencedStartupResumeArgv(env, codexResumeLaunch)
|
|
if (args.preAllocatedHandle) {
|
|
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
|
|
}
|
|
let selectedCodexHomePath = !args.connectionId
|
|
? getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
codexResumeHome
|
|
? codexResumeHome.codexHomePath
|
|
: (getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
|
workspacePath: cwd,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
|
|
}) ?? null)
|
|
)
|
|
: null
|
|
if (args.launchAgent === 'codex' && callerRequestedSessionId === undefined) {
|
|
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
|
|
selectedCodexHomePath,
|
|
getSettings: () => getSettings?.(),
|
|
requiredCodexHomePath: codexResumeHome?.codexHomePath,
|
|
target: codexSelectionTarget,
|
|
resolveCurrent: () =>
|
|
getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
|
workspacePath: cwd,
|
|
launchAgent: 'codex'
|
|
}) ?? null
|
|
),
|
|
resolveAfterUnavailable: (unavailableManagedHomePath) =>
|
|
getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
|
workspacePath: cwd,
|
|
launchAgent: 'codex',
|
|
unavailableManagedHomePath
|
|
}) ?? null
|
|
)
|
|
})
|
|
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
|
|
}
|
|
const codexResumeHomeSelected = Boolean(
|
|
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
|
|
)
|
|
const skipCodexHomeEnv =
|
|
isDaemonHostSpawn &&
|
|
shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
|
|
!selectedCodexHomePath
|
|
const stripInheritedOrcaCodexHome =
|
|
isDaemonHostSpawn &&
|
|
shouldStripInheritedOrcaCodexHome({
|
|
target: codexSelectionTarget,
|
|
selectedCodexHomePath,
|
|
skipCodexHomeEnv,
|
|
settings: getSettings?.()
|
|
})
|
|
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,
|
|
stripInheritedOrcaCodexHome,
|
|
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
|
launchCommand,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
|
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
|
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
|
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
|
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
|
networkProxySettings: getSettings?.(),
|
|
deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true
|
|
})
|
|
stampWslOrchestrationCompatibilityHost(
|
|
env,
|
|
runtime?.getOrchestrationCompatibilityHostId?.(),
|
|
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
|
)
|
|
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 } : {})
|
|
}
|
|
if (!args.connectionId && !isDaemonHostSpawn) {
|
|
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
|
}
|
|
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
|
|
if (startupTerminalColorQueryReplyColors) {
|
|
spawnOptions.startupIngress = {
|
|
colors: startupTerminalColorQueryReplyColors,
|
|
deadlineMs: 5_000
|
|
}
|
|
}
|
|
let ptySpawnCommitReported = false
|
|
const reportPtySpawnCommitted = (): void => {
|
|
if (ptySpawnCommitReported) {
|
|
return
|
|
}
|
|
ptySpawnCommitReported = true
|
|
args.onPtySpawnCommitted?.()
|
|
}
|
|
spawnOptions.envToDelete = mergePtyEnvDeletions(
|
|
authEnvToDelete,
|
|
args.envToDelete ?? [],
|
|
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [],
|
|
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
|
|
getInheritedClaudeSessionStampEnvKeysToDelete(env)
|
|
)
|
|
if (skipCodexHomeEnv) {
|
|
spawnOptions.envToDelete = mergePtyEnvDeletions(
|
|
spawnOptions.envToDelete,
|
|
CODEX_HOME_ENV_KEYS
|
|
)
|
|
} else if (stripInheritedOrcaCodexHome) {
|
|
// Why: the daemon owns a persistent inherited environment that may
|
|
// differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair.
|
|
spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [
|
|
'ORCA_CODEX_HOME'
|
|
])
|
|
}
|
|
if (codexResumeHomeSelected) {
|
|
spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete)
|
|
}
|
|
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
|
|
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
|
if (launchCommand !== undefined) {
|
|
spawnOptions.command = launchCommand
|
|
}
|
|
if (args.commandDelivery !== undefined) {
|
|
spawnOptions.commandDelivery = args.commandDelivery
|
|
}
|
|
if (args.startupCommandDelivery !== undefined) {
|
|
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
|
|
}
|
|
if (isTuiAgent(args.launchAgent)) {
|
|
spawnOptions.launchAgent = args.launchAgent
|
|
}
|
|
if (args.worktreeId !== undefined) {
|
|
spawnOptions.worktreeId = args.worktreeId
|
|
}
|
|
const hadSessionSizeBeforeAttach =
|
|
effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false
|
|
const sessionSizeBeforeAttach =
|
|
effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined
|
|
if (sessionId !== undefined) {
|
|
spawnOptions.sessionId = sessionId
|
|
ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows })
|
|
}
|
|
const materializedPaneKey = hostSessionBinding
|
|
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
|
|
: null
|
|
const metadataLeafId =
|
|
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
|
const metadataPaneKey =
|
|
typeof args.tabId === 'string' &&
|
|
isValidTerminalTabId(args.tabId) &&
|
|
args.tabId.length <= 512 &&
|
|
metadataLeafId
|
|
? makePaneKey(args.tabId, metadataLeafId)
|
|
: null
|
|
const spawnIdentityPaneKey = materializedPaneKey ?? metadataPaneKey
|
|
if (spawnIdentityPaneKey) {
|
|
spawnOptions.paneKey = spawnIdentityPaneKey
|
|
}
|
|
if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) {
|
|
spawnOptions.tabId = args.tabId
|
|
}
|
|
if (process.platform === 'win32' && !args.connectionId) {
|
|
spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride
|
|
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
|
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
|
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
|
: undefined
|
|
}
|
|
if (
|
|
args.agentSessionEnsure &&
|
|
(await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false
|
|
) {
|
|
// Why: runtime routing must select legacy before dispatch; never downgrade here after it began.
|
|
throw new Error('agent_session_claim_unavailable')
|
|
}
|
|
if (
|
|
args.agentSessionCreateOperationId &&
|
|
(await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false
|
|
) {
|
|
throw new Error('execution_owner_unavailable')
|
|
}
|
|
if (args.agentSessionEnsure) {
|
|
spawnOptions.agentSessionEnsure = args.agentSessionEnsure
|
|
}
|
|
if (args.agentSessionCreateOperationId) {
|
|
spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId
|
|
}
|
|
if (args.signal) {
|
|
spawnOptions.signal = args.signal
|
|
}
|
|
if (
|
|
args.onPtySpawnCommitted &&
|
|
(provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider))
|
|
) {
|
|
// Why: local fallback has no lower operation ledger, so commit must be reported at native spawn.
|
|
spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted
|
|
}
|
|
|
|
const existingPaneSpawn = materializedPaneKey
|
|
? paneSpawnReservationsByPaneKey.get(materializedPaneKey)
|
|
: undefined
|
|
if (existingPaneSpawn) {
|
|
return await existingPaneSpawn.promise
|
|
}
|
|
const finishTerminalInstall = beginPtySpawnForWorktree(
|
|
args.worktreeId,
|
|
cwd,
|
|
args.connectionId
|
|
)
|
|
const paneSpawnReservation = materializedPaneKey
|
|
? reservePaneSpawn(materializedPaneKey)
|
|
: null
|
|
let result: PtySpawnResult
|
|
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
|
let pendingRegistrationPtyId: string | null = null
|
|
let preparedProvisionalExecutionContext = false
|
|
let releaseWorktreeSpawn: (() => void) | undefined
|
|
try {
|
|
releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId)
|
|
try {
|
|
if (args.preAllocatedHandle) {
|
|
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
|
|
}
|
|
const expectedPtyId = effectiveSessionAppId ?? sessionId
|
|
if (expectedPtyId) {
|
|
runtime?.beginPtyRegistration?.(expectedPtyId)
|
|
pendingRegistrationPtyId = expectedPtyId
|
|
}
|
|
if (isDaemonHostSpawn && expectedPtyId) {
|
|
preparedProvisionalExecutionContext =
|
|
runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, {
|
|
resetIncarnation: isMintedSessionId,
|
|
preserveExisting: !isMintedSessionId
|
|
}) ?? false
|
|
}
|
|
const sequenceBeforeProviderSpawn = expectedPtyId
|
|
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
|
|
: 0
|
|
const assertClientStillConnected = (): void => {
|
|
if (args.signal?.aborted) {
|
|
throw new Error('client_disconnected')
|
|
}
|
|
}
|
|
if (args.agentSessionEnsure) {
|
|
// Why: daemon-backed claims can outlive this controller; import all
|
|
// proven owners before deciding that an identity is absent.
|
|
await reconcileAgentSessionOwnerListings()
|
|
const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim)
|
|
if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) {
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
|
|
}
|
|
runtime?.beginPtyRegistration?.(
|
|
recoveredOwner.ptyId,
|
|
ptyIncarnationById.get(recoveredOwner.ptyId)
|
|
)
|
|
pendingRegistrationPtyId = recoveredOwner.ptyId
|
|
}
|
|
let providerResult: PtySpawnResult | null = null
|
|
const ensured = await agentSessionOwners.ensure({
|
|
claim: args.agentSessionEnsure.claim,
|
|
surface: args.agentSessionEnsure.surface,
|
|
spawn: async () => {
|
|
assertClientStillConnected()
|
|
providerResult = await provider.spawn(spawnOptions)
|
|
rejectedRegistrationCandidate = providerResult
|
|
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
|
|
reportPtySpawnCommitted()
|
|
assertSpawnReplyWasLive(providerResult)
|
|
runtime?.assertPtyRegistrationAllowed?.(
|
|
providerResult.id,
|
|
providerResult.incarnationId
|
|
)
|
|
if (providerResult.incarnationId) {
|
|
// Why: local providers cannot serialize controller claims, so liveness proof
|
|
// needs the exact incarnation before the registry promotes the new owner.
|
|
ptyIncarnationById.set(providerResult.id, providerResult.incarnationId)
|
|
}
|
|
const providerEnsure = providerResult.agentSessionEnsure
|
|
return {
|
|
ptyId: providerResult.id,
|
|
...(providerEnsure
|
|
? {
|
|
owner: providerEnsure.owner,
|
|
disposition: providerEnsure.disposition
|
|
}
|
|
: {})
|
|
}
|
|
},
|
|
isLive: async (owner) => {
|
|
const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId)
|
|
if (!ownerProvider) {
|
|
// Why: a disconnected relay may keep its PTY alive during the
|
|
// grace window; missing transport is unknown, never absence.
|
|
throw new Error('execution_owner_unavailable')
|
|
}
|
|
return await isProviderAgentSessionOwnerLive(ownerProvider, owner)
|
|
}
|
|
})
|
|
result = providerResult ?? {
|
|
id: ensured.owner.ptyId,
|
|
isReattach: true,
|
|
// Why: adoption from an authoritative listing must preserve the
|
|
// incarnation proof used to reject a delayed exit from an older process.
|
|
incarnationId: ptyIncarnationById.get(ensured.owner.ptyId)
|
|
}
|
|
result.agentSessionEnsure = ensured
|
|
} else {
|
|
assertClientStillConnected()
|
|
result = await provider.spawn(spawnOptions)
|
|
rejectedRegistrationCandidate = result
|
|
// Why: daemon/relay returns cross the physical commit boundary before controller admission.
|
|
reportPtySpawnCommitted()
|
|
assertSpawnReplyWasLive(result)
|
|
}
|
|
rejectedRegistrationCandidate ??= result
|
|
if (pendingRegistrationPtyId !== result.id) {
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
|
|
}
|
|
runtime?.beginPtyRegistration?.(result.id, result.incarnationId)
|
|
pendingRegistrationPtyId = result.id
|
|
}
|
|
// Why: admission precedes sequence/context state and every durable publication below.
|
|
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
|
if (result.providerSequence) {
|
|
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
|
result.id,
|
|
result.providerSequence,
|
|
sequenceBeforeProviderSpawn
|
|
)
|
|
}
|
|
runtime?.preparePtyExecutionContext?.(
|
|
result.id,
|
|
args.connectionId
|
|
? null
|
|
: result.wslDistro === undefined
|
|
? expectedWslDistro
|
|
: result.wslDistro
|
|
)
|
|
} catch (err) {
|
|
if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) {
|
|
runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, {
|
|
resetIncarnation: true
|
|
})
|
|
}
|
|
const rawMessage = err instanceof Error ? err.message : String(err)
|
|
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
|
|
runtime?.releaseRejectedPtyRegistrationFence?.(
|
|
rejectedRegistrationCandidate.id,
|
|
rejectedRegistrationCandidate.incarnationId
|
|
)
|
|
}
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(
|
|
pendingRegistrationPtyId,
|
|
rejectedRegistrationCandidate?.incarnationId
|
|
)
|
|
pendingRegistrationPtyId = null
|
|
}
|
|
const spawnError = normalizeNodePtySpawnError(err)
|
|
const isIdentityMismatch =
|
|
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
|
|
if (effectiveSessionAppId !== undefined) {
|
|
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
|
|
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
|
|
} else {
|
|
ptySizes.delete(effectiveSessionAppId)
|
|
}
|
|
}
|
|
if (
|
|
args.connectionId &&
|
|
effectiveSessionRelayId !== undefined &&
|
|
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
|
|
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
|
|
) {
|
|
if (effectiveSessionAppId !== undefined && !isIdentityMismatch) {
|
|
clearProviderPtyState(effectiveSessionAppId)
|
|
deletePtyOwnership(effectiveSessionAppId)
|
|
}
|
|
if (!isIdentityMismatch) {
|
|
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
|
}
|
|
}
|
|
if (isMintedSessionId && sessionId !== undefined) {
|
|
clearProviderPtyState(sessionId)
|
|
}
|
|
throw spawnError
|
|
} finally {
|
|
if (args.preAllocatedHandle) {
|
|
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
|
|
}
|
|
}
|
|
if (result.agentSessionEnsure?.disposition === 'adopted') {
|
|
const owner = result.agentSessionEnsure.owner
|
|
ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null)
|
|
runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle)
|
|
if (result.incarnationId) {
|
|
ptyIncarnationById.set(result.id, result.incarnationId)
|
|
}
|
|
runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, {
|
|
tabId: owner.surface.tabId,
|
|
leafId: owner.surface.leafId,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
|
})
|
|
return {
|
|
id: result.id,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
|
agentSessionEnsure: result.agentSessionEnsure
|
|
}
|
|
}
|
|
ptyOwnership.set(result.id, args.connectionId ?? null)
|
|
if (result.incarnationId) {
|
|
ptyIncarnationById.set(result.id, result.incarnationId)
|
|
}
|
|
// Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero.
|
|
if (
|
|
isNativeWindowsLocalPtySpawn({
|
|
connectionId: args.connectionId,
|
|
cwd: args.cwd,
|
|
shellOverride: daemonShellOverride
|
|
})
|
|
) {
|
|
markNativeWindowsConptyPty(result.id)
|
|
}
|
|
const relayResultId = getRelayPtyId(args.connectionId, result.id)
|
|
const persistSshLease = (): void => {
|
|
if (!store || !args.connectionId) {
|
|
return
|
|
}
|
|
// Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration.
|
|
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)
|
|
}
|
|
recordCodexPaneAccountForSpawn({
|
|
ptyId: result.id,
|
|
isDaemonHostSpawn,
|
|
isReattach: result.isReattach === true,
|
|
pinnedByResume: codexResumeHomeSelected,
|
|
launchCodexHomePath: selectedCodexHomePath,
|
|
launchEnv: args.env,
|
|
target: codexSelectionTarget,
|
|
settings: getSettings?.()
|
|
})
|
|
if (hostSessionBinding) {
|
|
try {
|
|
const binding = {
|
|
worktreeId: hostSessionBinding.worktreeId,
|
|
tabId: hostSessionBinding.tabId,
|
|
leafId: hostSessionBinding.leafId,
|
|
ptyId: result.id,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
|
...(cwd ? { startupCwd: cwd } : {})
|
|
}
|
|
if (args.connectionId) {
|
|
hostSessionBinding.store.persistPtyBinding(
|
|
binding,
|
|
toSshExecutionHostId(args.connectionId)
|
|
)
|
|
} else {
|
|
hostSessionBinding.store.persistPtyBinding(binding)
|
|
}
|
|
} 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 Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
|
agentSessionOperationOutcome: 'unknown' as const
|
|
})
|
|
}
|
|
persistSshLease()
|
|
}
|
|
if (args.preAllocatedHandle) {
|
|
runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle)
|
|
}
|
|
if (args.worktreeId) {
|
|
runtime?.registerPty(
|
|
result.id,
|
|
args.worktreeId,
|
|
args.connectionId ?? null,
|
|
// Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587).
|
|
typeof args.tabId === 'string' &&
|
|
isValidTerminalTabId(args.tabId) &&
|
|
args.tabId.length <= 512 &&
|
|
metadataLeafId !== null
|
|
? {
|
|
tabId: args.tabId,
|
|
leafId: metadataLeafId,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
|
}
|
|
: undefined,
|
|
!args.connectionId
|
|
? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd)
|
|
: undefined
|
|
)
|
|
} else {
|
|
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
|
|
runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId)
|
|
}
|
|
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
|
|
runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? 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; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
|
|
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
|
|
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
|
|
const inheritRendererReadiness =
|
|
result.isReattach === true &&
|
|
!pendingSerializer &&
|
|
rendererSerializerReadiness.has(result.id)
|
|
rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness)
|
|
if (paneKey && pendingSerializer) {
|
|
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id)
|
|
}
|
|
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
|
|
})
|
|
}
|
|
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
|
sendPtySpawnedToRenderer(result.id)
|
|
const response = {
|
|
id: result.id,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
|
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
|
|
}
|
|
return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response)
|
|
} catch (err) {
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(
|
|
pendingRegistrationPtyId,
|
|
rejectedRegistrationCandidate?.incarnationId
|
|
)
|
|
pendingRegistrationPtyId = null
|
|
}
|
|
// 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
|
|
} finally {
|
|
releaseWorktreeSpawn?.()
|
|
finishTerminalInstall()
|
|
}
|
|
},
|
|
write: (ptyId, data) => {
|
|
try {
|
|
getProviderForPty(ptyId).write(ptyId, data)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
},
|
|
kill: (ptyId) => {
|
|
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
|
|
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
|
connectionId ??= parsedSshId?.connectionId
|
|
const killWithCurrentProvider = (): boolean => {
|
|
let provider: IPtyProvider
|
|
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.
|
|
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
|
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
|
rememberSyntheticKillExit(ptyId)
|
|
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
|
|
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
|
|
.then((providerExitObserved) => {
|
|
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
|
if (!providerExitObserved) {
|
|
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
|
rememberSyntheticKillExit(ptyId)
|
|
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (isPtyAlreadyGoneError(err)) {
|
|
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
|
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
|
rememberSyntheticKillExit(ptyId)
|
|
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
|
return
|
|
}
|
|
console.warn(
|
|
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
|
)
|
|
// Why: close runtime tails without clearing provider ownership, so
|
|
// a retry can still target a PTY that survived the failed shutdown.
|
|
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
|
})
|
|
return true
|
|
}
|
|
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
|
|
if (startupPromise) {
|
|
// Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY.
|
|
void startupPromise.then(killWithCurrentProvider).catch((err) => {
|
|
console.warn(
|
|
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
|
)
|
|
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
|
})
|
|
return true
|
|
}
|
|
return killWithCurrentProvider()
|
|
},
|
|
markReversibleStops: (ptyIds) => {
|
|
for (const ptyId of ptyIds) {
|
|
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
|
|
}
|
|
let released = false
|
|
return () => {
|
|
if (released) {
|
|
return
|
|
}
|
|
released = true
|
|
for (const ptyId of ptyIds) {
|
|
const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1
|
|
if (owners > 0) {
|
|
reversibleStopOwnersByPtyId.set(ptyId, owners)
|
|
} else {
|
|
reversibleStopOwnersByPtyId.delete(ptyId)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
stopAndWait: async (ptyId, opts) => {
|
|
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
|
|
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
|
connectionId ??= parsedSshId?.connectionId
|
|
// Why: destructive teardown threads one absolute deadline through every await
|
|
// below; each RPC leaf converts it to the remaining time when it issues, so
|
|
// sequential RPCs share the budget and cannot overrun the sweep deadline.
|
|
const deadlineMs = opts?.deadlineMs
|
|
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
|
|
if (startupPromise) {
|
|
// Why: exact-stop must resolve the provider after daemon startup just
|
|
// like renderer kills, or the fallback can falsely confirm teardown.
|
|
if (deadlineMs !== undefined) {
|
|
// Why: bound the cold-start await by the teardown deadline instead of the
|
|
// 60s startup fail-open cap; fail closed so the sweep records the miss.
|
|
const won = await Promise.race([
|
|
// Why: () => false on rejection both fails closed on a startup error and
|
|
// keeps the losing branch's rejection from surfacing as unhandled.
|
|
startupPromise.then(
|
|
() => true,
|
|
() => false
|
|
),
|
|
delay(Math.max(1, deadlineMs - Date.now())).then(() => false)
|
|
])
|
|
if (!won) {
|
|
return false
|
|
}
|
|
} else {
|
|
await startupPromise
|
|
}
|
|
}
|
|
let provider: IPtyProvider
|
|
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.
|
|
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
|
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
|
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,
|
|
deadlineMs
|
|
})
|
|
} 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
|
|
}
|
|
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
|
if (!providerExitObserved) {
|
|
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
|
rememberSyntheticKillExit(ptyId)
|
|
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
|
}
|
|
return true
|
|
},
|
|
getForegroundProcess: async (ptyId) => {
|
|
try {
|
|
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
|
|
} catch {
|
|
return null
|
|
}
|
|
},
|
|
inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId),
|
|
confirmForegroundProcess: async (ptyId) => {
|
|
try {
|
|
const provider = getProviderForPty(ptyId)
|
|
// Why: cached foreground evidence cannot resolve a fresh shell conflict.
|
|
return (await provider.confirmForegroundProcess?.(ptyId)) ?? null
|
|
} 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 and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't 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 */
|
|
}
|
|
},
|
|
hasPty: (ptyId) => {
|
|
try {
|
|
return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
},
|
|
listProcesses: async (connectionId) => {
|
|
if (connectionId === null) {
|
|
return localProvider.listProcesses()
|
|
}
|
|
if (connectionId !== undefined) {
|
|
return getProvider(connectionId).listProcesses()
|
|
}
|
|
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's exact screen state/dimensions before live TUI chunks render correctly.
|
|
return requestSerializedBuffer(ptyId, opts)
|
|
},
|
|
serializeProviderBuffer: async (ptyId, opts) => {
|
|
try {
|
|
// Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works.
|
|
return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
},
|
|
hasRendererSerializer: (ptyId) => {
|
|
// Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative).
|
|
return rendererSerializerReadiness.has(ptyId)
|
|
},
|
|
getRendererSerializerGeneration: (ptyId) => {
|
|
return rendererSerializerReadiness.generation(ptyId)
|
|
},
|
|
waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => {
|
|
return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal)
|
|
},
|
|
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
|
|
pendingDeliveryStartSeq?: number
|
|
source?: 'headless' | 'renderer'
|
|
alternateScreen?: boolean
|
|
scrollbackAnsi?: string
|
|
pendingEscapeTailAnsi?: string
|
|
} | null> => {
|
|
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
|
|
return null
|
|
}
|
|
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
|
|
try {
|
|
const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id)
|
|
const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id)
|
|
const providerSnapshot = providerSnapshotRequired
|
|
? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, {
|
|
scrollbackRows
|
|
})
|
|
: null
|
|
// Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback.
|
|
if (providerSnapshotRequired && !providerSnapshot) {
|
|
return null
|
|
}
|
|
const snapshot =
|
|
providerSnapshot ??
|
|
(await runtime.serializeHiddenOutputRecoveryBuffer(args.id, {
|
|
scrollbackRows
|
|
}))
|
|
if (!snapshot || typeof snapshot.seq !== 'number') {
|
|
return snapshot
|
|
}
|
|
// Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter.
|
|
const pending = pendingData.get(args.id)
|
|
if (pending && typeof pending.startSeq !== 'number') {
|
|
// Why: a seq-less backlog cannot be bounded — stay conservative.
|
|
return snapshot
|
|
}
|
|
return {
|
|
...snapshot,
|
|
pendingDeliveryStartSeq: Math.min(
|
|
pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq),
|
|
snapshot.seq
|
|
)
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
)
|
|
|
|
// Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md).
|
|
ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => {
|
|
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
|
|
return null
|
|
}
|
|
return runtime.getTerminalSideEffectSnapshot(args.id)
|
|
})
|
|
|
|
installPowerSignalBreadcrumbs()
|
|
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
|
|
// Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient.
|
|
cwdFallback?: 'worktree'
|
|
env?: Record<string, string>
|
|
envToDelete?: string[]
|
|
command?: string
|
|
commandDelivery?: 'renderer' | 'provider'
|
|
launchConfig?: SleepingAgentLaunchConfig
|
|
resumeProviderSession?: AgentProviderSessionMetadata
|
|
launchAgent?: TuiAgent
|
|
startupCommandDelivery?: StartupCommandDelivery
|
|
connectionId?: string | null
|
|
worktreeId?: string
|
|
sessionId?: string
|
|
shellOverride?: string
|
|
projectRuntime?: ProjectExecutionRuntimeResolution
|
|
terminalColorQueryReplies?: {
|
|
foreground?: unknown
|
|
background?: unknown
|
|
}
|
|
// Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries.
|
|
initiallyHidden?: boolean
|
|
// Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these.
|
|
tabId?: string
|
|
leafId?: string
|
|
// Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point.
|
|
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)
|
|
// Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem.
|
|
const allowMissingCwdFallback =
|
|
!args.connectionId && !args.sessionId && args.cwdFallback === 'worktree'
|
|
let didFallbackToWorkspaceRootCwd = false
|
|
const cwd = resolvePtySpawnStartupCwd(
|
|
args.worktreeId,
|
|
args.cwd,
|
|
allowMissingCwdFallback
|
|
? {
|
|
directoryExists: localStartupCwdDirectoryExists,
|
|
onFallbackToWorkspaceRoot: () => {
|
|
didFallbackToWorkspaceRootCwd = true
|
|
}
|
|
}
|
|
: undefined
|
|
)
|
|
const startupCwdFallback =
|
|
didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined
|
|
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 isDaemonHostSpawn =
|
|
!args.connectionId &&
|
|
!(provider instanceof LocalPtyProvider) &&
|
|
!routesFreshSpawnsToLocalProvider(provider)
|
|
// Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint().
|
|
// Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state.
|
|
// Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber.
|
|
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 expectedWslDistro = !args.connectionId
|
|
? (resolveWslSessionContext({
|
|
cwd,
|
|
sessionId: effectiveSessionId,
|
|
shellOverride: terminalRuntimeOptions.shellOverride,
|
|
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
|
})?.distro ?? null)
|
|
: null
|
|
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
|
initialShellOverride,
|
|
cwd,
|
|
expectedWslDistro
|
|
)
|
|
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 skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
|
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
|
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
|
|
// Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't 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' &&
|
|
isValidTerminalTabId(args.tabId) &&
|
|
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: Agent Teams ids/tokens are process-local, so the team env 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/hook registries; only a key proven to match this spawn's tab+leaf may cross 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 off; IPC tab/leaf metadata still names the pane.
|
|
const reservationPaneKey = metadataPaneKey ?? validatedPaneKey
|
|
const validatedLeafId = verifiedLeafId ?? metadataLeafId
|
|
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
|
|
const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({
|
|
connectionId: args.connectionId,
|
|
cwd: args.cwd,
|
|
shellOverride: effectiveShellOverride
|
|
})
|
|
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
|
effectiveShellOverride,
|
|
cwd,
|
|
expectedWslDistro
|
|
)
|
|
const codexResumePreparation = prepareCodexResumeHome({
|
|
connectionId: args.connectionId,
|
|
launchAgent: args.launchAgent,
|
|
providerSession: args.resumeProviderSession,
|
|
target: codexSelectionTarget,
|
|
launchEnv: baseEnv,
|
|
workspacePath: cwd
|
|
})
|
|
const codexResumeLaunch = codexResumePreparation
|
|
? await resolveCodexResumeLaunch(args.command, codexResumePreparation)
|
|
: noCodexResumeLaunch(args.command)
|
|
const codexResumeHome = codexResumeLaunch.codexResumeHome
|
|
const launchCommand = codexResumeLaunch.command
|
|
baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch)
|
|
// Why: declared after the strip so a local-provider spawn cannot capture the
|
|
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
|
|
let env: Record<string, string> | undefined = baseEnv
|
|
let selectedCodexHomePath = !args.connectionId
|
|
? getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
codexResumeHome
|
|
? codexResumeHome.codexHomePath
|
|
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
|
workspacePath: cwd,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
|
|
}) ?? null)
|
|
)
|
|
: null
|
|
if (args.launchAgent === 'codex' && args.sessionId === undefined) {
|
|
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
|
|
selectedCodexHomePath,
|
|
getSettings: () => getSettings?.(),
|
|
requiredCodexHomePath: codexResumeHome?.codexHomePath,
|
|
target: codexSelectionTarget,
|
|
resolveCurrent: () =>
|
|
getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
|
workspacePath: cwd,
|
|
launchAgent: 'codex'
|
|
}) ?? null
|
|
),
|
|
resolveAfterUnavailable: (unavailableManagedHomePath) =>
|
|
getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
|
workspacePath: cwd,
|
|
launchAgent: 'codex',
|
|
unavailableManagedHomePath
|
|
}) ?? null
|
|
)
|
|
})
|
|
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
|
|
}
|
|
const codexResumeHomeSelected = Boolean(
|
|
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
|
|
)
|
|
const skipCodexHomeEnv =
|
|
isDaemonHostSpawn &&
|
|
shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
|
|
!selectedCodexHomePath
|
|
const stripInheritedOrcaCodexHome =
|
|
isDaemonHostSpawn &&
|
|
shouldStripInheritedOrcaCodexHome({
|
|
target: codexSelectionTarget,
|
|
selectedCodexHomePath,
|
|
skipCodexHomeEnv,
|
|
settings: getSettings?.()
|
|
})
|
|
if (isDaemonHostSpawn) {
|
|
if (effectiveSessionId === undefined) {
|
|
// Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth.
|
|
throw new Error('Invariant violation: daemon spawn without sessionId')
|
|
}
|
|
const sessionIdForEnv = effectiveSessionId
|
|
// Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots.
|
|
if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) {
|
|
throw new Error('Invalid PTY session id')
|
|
}
|
|
// Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it).
|
|
env = { ...baseEnv }
|
|
try {
|
|
buildPtyHostEnv(sessionIdForEnv, env, {
|
|
isPackaged: app.isPackaged,
|
|
userDataPath: app.getPath('userData'),
|
|
selectedCodexHomePath,
|
|
skipCodexHomeEnv,
|
|
stripInheritedOrcaCodexHome,
|
|
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
|
launchCommand,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
|
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
|
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
|
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
|
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
|
networkProxySettings: getSettings?.(),
|
|
deferGitConfigGuardToDaemon:
|
|
provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true
|
|
})
|
|
stampWslOrchestrationCompatibilityHost(
|
|
env,
|
|
runtime?.getOrchestrationCompatibilityHostId?.(),
|
|
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
|
)
|
|
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
|
} catch (err) {
|
|
// Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs.
|
|
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
|
|
let combinedEnvToDelete = mergePtyEnvDeletions(
|
|
envToDelete,
|
|
args.envToDelete ?? [],
|
|
agentTeamsEnvToDelete ?? [],
|
|
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [],
|
|
getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv),
|
|
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
|
|
// Why: the persistent daemon compares its own merged CODEX_HOME pair;
|
|
// main cannot safely decide ownership for a process it may not parent.
|
|
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
|
|
)
|
|
if (codexResumeHomeSelected) {
|
|
combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete)
|
|
}
|
|
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
|
|
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
|
|
const spawnOptions: PtySpawnOptions = {
|
|
cols: args.cols,
|
|
rows: args.rows,
|
|
cwd,
|
|
env: spawnEnv,
|
|
...(isMintedSessionId ? { isNewSession: true } : {})
|
|
}
|
|
if (!args.connectionId && !isDaemonHostSpawn) {
|
|
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
|
}
|
|
if (combinedEnvToDelete) {
|
|
spawnOptions.envToDelete = combinedEnvToDelete
|
|
}
|
|
if (launchCommand !== undefined) {
|
|
spawnOptions.command = launchCommand
|
|
}
|
|
if (args.commandDelivery !== undefined) {
|
|
spawnOptions.commandDelivery = args.commandDelivery
|
|
}
|
|
if (args.startupCommandDelivery !== undefined) {
|
|
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
|
|
}
|
|
if (isTuiAgent(args.launchAgent)) {
|
|
spawnOptions.launchAgent = args.launchAgent
|
|
}
|
|
if (args.worktreeId !== undefined) {
|
|
spawnOptions.worktreeId = args.worktreeId
|
|
}
|
|
if (reservationPaneKey) {
|
|
spawnOptions.paneKey = reservationPaneKey
|
|
}
|
|
if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) {
|
|
spawnOptions.tabId = args.tabId
|
|
}
|
|
if (effectiveSessionId !== undefined) {
|
|
spawnOptions.sessionId = effectiveSessionId
|
|
}
|
|
// Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()).
|
|
if (effectiveShellOverride !== undefined) {
|
|
spawnOptions.shellOverride = effectiveShellOverride
|
|
}
|
|
const hadSessionSizeBeforeAttach =
|
|
effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false
|
|
const sessionSizeBeforeAttach =
|
|
effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined
|
|
if (effectiveSessionId !== undefined) {
|
|
// Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes 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 models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable.
|
|
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
|
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
|
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
|
: undefined
|
|
}
|
|
if (startupTerminalColorQueryReplyColors) {
|
|
spawnOptions.startupIngress = {
|
|
colors: startupTerminalColorQueryReplyColors,
|
|
deadlineMs: 5_000
|
|
}
|
|
}
|
|
const existingPaneSpawn = reservationPaneKey
|
|
? paneSpawnReservationsByPaneKey.get(reservationPaneKey)
|
|
: undefined
|
|
if (existingPaneSpawn) {
|
|
return await existingPaneSpawn.promise
|
|
}
|
|
const finishTerminalInstall = beginPtySpawnForWorktree(
|
|
args.worktreeId,
|
|
cwd,
|
|
args.connectionId
|
|
)
|
|
const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null
|
|
const initiallyHidden = args.initiallyHidden === true
|
|
// Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below.
|
|
const preSpawnHiddenMarkId =
|
|
initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined
|
|
? effectiveSessionAppId
|
|
: null
|
|
if (preSpawnHiddenMarkId !== null) {
|
|
transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true)
|
|
}
|
|
let result: PtySpawnResult
|
|
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
|
let pendingRegistrationPtyId: string | null = null
|
|
let preparedProvisionalExecutionContext = false
|
|
let releaseWorktreeSpawn: (() => void) | undefined
|
|
try {
|
|
releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId)
|
|
try {
|
|
if (preAllocatedHandle) {
|
|
trustedTerminalHandleEnv.add(preAllocatedHandle)
|
|
}
|
|
spawnTiming.mark('options')
|
|
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
|
|
if (expectedPtyId) {
|
|
runtime?.beginPtyRegistration?.(expectedPtyId)
|
|
pendingRegistrationPtyId = expectedPtyId
|
|
}
|
|
if (isDaemonHostSpawn && expectedPtyId) {
|
|
preparedProvisionalExecutionContext =
|
|
runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, {
|
|
resetIncarnation: isMintedSessionId,
|
|
preserveExisting: !isMintedSessionId
|
|
}) ?? false
|
|
}
|
|
const sequenceBeforeProviderSpawn = expectedPtyId
|
|
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
|
|
: 0
|
|
result = await provider.spawn(spawnOptions)
|
|
rejectedRegistrationCandidate = result
|
|
if (pendingRegistrationPtyId !== result.id) {
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
|
|
}
|
|
runtime?.beginPtyRegistration?.(result.id, result.incarnationId)
|
|
pendingRegistrationPtyId = result.id
|
|
}
|
|
assertSpawnReplyWasLive(result)
|
|
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
|
if (result.providerSequence) {
|
|
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
|
result.id,
|
|
result.providerSequence,
|
|
sequenceBeforeProviderSpawn
|
|
)
|
|
}
|
|
runtime?.preparePtyExecutionContext?.(
|
|
result.id,
|
|
args.connectionId
|
|
? null
|
|
: result.wslDistro === undefined
|
|
? expectedWslDistro
|
|
: result.wslDistro
|
|
)
|
|
spawnTiming.mark('provider_spawn')
|
|
} catch (err) {
|
|
if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) {
|
|
runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, {
|
|
resetIncarnation: true
|
|
})
|
|
}
|
|
// Why: a stale hidden mark on this session id would gate a later visible attach that reuses it.
|
|
if (preSpawnHiddenMarkId !== null) {
|
|
transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false)
|
|
}
|
|
const rawMessage = err instanceof Error ? err.message : String(err)
|
|
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
|
|
runtime?.releaseRejectedPtyRegistrationFence?.(
|
|
rejectedRegistrationCandidate.id,
|
|
rejectedRegistrationCandidate.incarnationId
|
|
)
|
|
}
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(
|
|
pendingRegistrationPtyId,
|
|
rejectedRegistrationCandidate?.incarnationId
|
|
)
|
|
pendingRegistrationPtyId = null
|
|
}
|
|
const spawnError = normalizeNodePtySpawnError(err)
|
|
const isIdentityMismatch =
|
|
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
|
|
if (effectiveSessionAppId !== undefined) {
|
|
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
|
|
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
|
|
} else {
|
|
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 = relay already dropped the PTY; clear the lease so writes can't restore the stale binding.
|
|
if (effectiveSessionAppId !== undefined && !isIdentityMismatch) {
|
|
clearProviderPtyState(effectiveSessionAppId)
|
|
deletePtyOwnership(effectiveSessionAppId)
|
|
}
|
|
if (!isIdentityMismatch) {
|
|
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
|
}
|
|
}
|
|
// Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed.
|
|
if (isMintedSessionId && effectiveSessionId !== undefined) {
|
|
clearProviderPtyState(effectiveSessionId)
|
|
}
|
|
// Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the 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
|
|
})
|
|
recordCodexPaneAccountForSpawn({
|
|
ptyId: result.id,
|
|
isDaemonHostSpawn,
|
|
isReattach: result.isReattach === true,
|
|
pinnedByResume: codexResumeHomeSelected,
|
|
launchCodexHomePath: selectedCodexHomePath,
|
|
launchEnv: baseEnv,
|
|
target: codexSelectionTarget,
|
|
settings: getSettings?.()
|
|
})
|
|
ptyOwnership.set(result.id, args.connectionId ?? null)
|
|
if (result.incarnationId) {
|
|
ptyIncarnationById.set(result.id, result.incarnationId)
|
|
}
|
|
if (initiallyHidden) {
|
|
// Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn).
|
|
transitionSpawnHiddenRendererPtyDeliveryState(result.id, true)
|
|
if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) {
|
|
// Defense: never strand a mark on an id the provider renamed.
|
|
transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false)
|
|
}
|
|
// Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood.
|
|
syncPtyBackgroundedDelivery(result.id, 'spawn')
|
|
closeStartupQueryAuthorityForPty(result.id)
|
|
}
|
|
// Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero.
|
|
if (nativeWindowsConptySpawn) {
|
|
markNativeWindowsConptyPty(result.id)
|
|
}
|
|
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 IDs immediately so reconnect reattaches instead of spawning 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: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217).
|
|
if (
|
|
store &&
|
|
typeof args.worktreeId === 'string' &&
|
|
typeof args.tabId === 'string' &&
|
|
validatedLeafId !== null
|
|
) {
|
|
try {
|
|
const binding = {
|
|
worktreeId: args.worktreeId,
|
|
tabId: args.tabId,
|
|
leafId: validatedLeafId,
|
|
ptyId: result.id,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
|
...(cwd ? { startupCwd: cwd } : {})
|
|
}
|
|
if (args.connectionId) {
|
|
store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
|
|
} else {
|
|
store.persistPtyBinding(binding)
|
|
}
|
|
} 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 Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
|
agentSessionOperationOutcome: 'unknown' as const
|
|
})
|
|
}
|
|
}
|
|
// Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md.
|
|
const rendererPreSignaled = validatedPaneKey
|
|
? pendingByPaneKey.has(validatedPaneKey)
|
|
: false
|
|
const rendererAlreadyRegistered =
|
|
result.isReattach === true &&
|
|
!rendererPreSignaled &&
|
|
rendererSerializerReadiness.has(result.id)
|
|
rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered)
|
|
// Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry.
|
|
if (validatedPaneKey && rendererPreSignaled) {
|
|
const pending = pendingByPaneKey.get(validatedPaneKey)
|
|
if (pending) {
|
|
pendingPtyIdBySerializerGeneration.set(pending.gen, result.id)
|
|
}
|
|
}
|
|
|
|
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
|
|
// Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot.
|
|
if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) {
|
|
const snapshotSeedSize =
|
|
typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number'
|
|
? { cols: result.snapshotCols, rows: result.snapshotRows }
|
|
: undefined
|
|
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
|
|
// Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md).
|
|
runtime.seedHeadlessTerminal(
|
|
result.id,
|
|
result.snapshot,
|
|
snapshotSeedSize,
|
|
typeof result.snapshotKittyKeyboardFlags === 'number'
|
|
? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags }
|
|
: {}
|
|
)
|
|
} else if (
|
|
result.coldRestore &&
|
|
typeof result.coldRestore.scrollback === 'string' &&
|
|
result.coldRestore.scrollback.length > 0
|
|
) {
|
|
const coldRestoreSeedSize =
|
|
typeof result.coldRestore.cols === 'number' &&
|
|
typeof result.coldRestore.rows === 'number'
|
|
? { cols: result.coldRestore.cols, rows: result.coldRestore.rows }
|
|
: undefined
|
|
runtime.seedHeadlessTerminal(
|
|
result.id,
|
|
result.coldRestore.scrollback,
|
|
coldRestoreSeedSize,
|
|
{
|
|
cwd: result.coldRestore.cwd,
|
|
oscLinks: result.coldRestore.oscLinks,
|
|
preferProviderIfExisting: true
|
|
}
|
|
)
|
|
} else if (typeof result.replay === 'string' && result.replay.length > 0) {
|
|
// Why: the relay reattach replay is the one restore main never ingests, so without
|
|
// this seed its model is a mere suffix of what the renderer painted — and a later
|
|
// park-reveal would outrank the fuller relay replay with that fragment.
|
|
runtime.seedHeadlessTerminal(result.id, result.replay)
|
|
}
|
|
}
|
|
if (
|
|
typeof args.worktreeId === 'string' &&
|
|
args.worktreeId.length > 0 &&
|
|
args.worktreeId.length <= 512
|
|
) {
|
|
runtime?.registerPty(
|
|
result.id,
|
|
args.worktreeId,
|
|
args.connectionId ?? null,
|
|
// Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId.
|
|
typeof args.tabId === 'string' &&
|
|
isValidTerminalTabId(args.tabId) &&
|
|
args.tabId.length <= 512 &&
|
|
metadataLeafId !== null
|
|
? {
|
|
tabId: args.tabId,
|
|
leafId: metadataLeafId,
|
|
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
|
}
|
|
: undefined,
|
|
!args.connectionId
|
|
? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd)
|
|
: undefined
|
|
)
|
|
pendingRegistrationPtyId = null
|
|
} else if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId)
|
|
pendingRegistrationPtyId = null
|
|
}
|
|
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
|
|
runtime?.noteTerminalSpawnCommand?.(
|
|
result.id,
|
|
typeof launchCommand === 'string' ? launchCommand : null
|
|
)
|
|
if (isClaudeLaunch) {
|
|
markClaudePtySpawned(result.id)
|
|
}
|
|
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
|
|
// Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState.
|
|
const rememberedPaneKey = validatedPaneKey
|
|
? rememberPaneKeyForPty(result.id, validatedPaneKey)
|
|
: null
|
|
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
|
|
agentHookServer.registerPaneKeyAlias(
|
|
legacySpawnPaneKey.paneKey,
|
|
migrationUnsupportedPaneKey,
|
|
result.id,
|
|
Date.now(),
|
|
{ authorityVerified: true }
|
|
)
|
|
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
|
|
} else if (validatedPaneKey) {
|
|
if (!result.isReattach) {
|
|
clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey)
|
|
}
|
|
}
|
|
// Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`.
|
|
if (!args.connectionId) {
|
|
// Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs).
|
|
const spawnedPid = result.pid ?? null
|
|
// Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps.
|
|
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 spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
|
|
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 }
|
|
: {}),
|
|
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
|
|
...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}),
|
|
// Why: the pane asked to resume and got a fresh session instead; only the
|
|
// renderer can say so, and a reattach never ran this launch command.
|
|
...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach
|
|
? { agentResumeUnavailable: true as const }
|
|
: {})
|
|
}
|
|
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
|
sendPtySpawnedToRenderer(result.id)
|
|
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
|
|
} catch (err) {
|
|
if (pendingRegistrationPtyId) {
|
|
runtime?.cancelPendingPtyRegistration?.(
|
|
pendingRegistrationPtyId,
|
|
rejectedRegistrationCandidate?.incarnationId
|
|
)
|
|
pendingRegistrationPtyId = null
|
|
}
|
|
// 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
|
|
} finally {
|
|
releaseWorktreeSpawn?.()
|
|
finishTerminalInstall()
|
|
}
|
|
}
|
|
)
|
|
|
|
const reportUnavailablePtyWrite = (id: string, error: unknown): void => {
|
|
if (
|
|
!isPtyWriteUnavailableError(error) ||
|
|
mainWindow.isDestroyed() ||
|
|
(typeof mainWindow.webContents.isDestroyed === 'function' &&
|
|
mainWindow.webContents.isDestroyed())
|
|
) {
|
|
return
|
|
}
|
|
mainWindow.webContents.send('pty:writeUnavailable', { id })
|
|
}
|
|
|
|
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((error) => {
|
|
reportUnavailablePtyWrite(id, error)
|
|
return false
|
|
})
|
|
} catch (error) {
|
|
reportUnavailablePtyWrite(id, error)
|
|
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 (error) {
|
|
reportUnavailablePtyWrite(id, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
type PtyWritePayload = { id: string; data: string }
|
|
type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
|
|
|
|
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 isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload =>
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
typeof (value as { id?: unknown }).id === 'string' &&
|
|
(value as { id: string }).id.length > 0 &&
|
|
typeof (value as { cols?: unknown }).cols === 'number' &&
|
|
Number.isFinite((value as { cols: number }).cols) &&
|
|
typeof (value as { rows?: unknown }).rows === 'number' &&
|
|
Number.isFinite((value as { rows: number }).rows) &&
|
|
(value as { cols: number }).cols > 0 &&
|
|
(value as { rows: number }).rows > 0
|
|
|
|
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: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. 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 ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet.
|
|
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
|
|
}
|
|
}
|
|
|
|
const hostViewportClaimTails = new Map<string, Promise<boolean>>()
|
|
|
|
ipcMain.on('pty:write', (event, args: unknown) => {
|
|
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
|
|
return
|
|
}
|
|
const claimTail = hostViewportClaimTails.get(args.id)
|
|
if (claimTail) {
|
|
void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false))
|
|
return
|
|
}
|
|
writePtyInput(args)
|
|
})
|
|
ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
|
|
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
|
|
return false
|
|
}
|
|
const claimTail = hostViewportClaimTails.get(args.id)
|
|
return claimTail
|
|
? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false))
|
|
: writePtyInputAccepted(args)
|
|
})
|
|
|
|
ipcMain.removeAllListeners('pty:claimViewport')
|
|
ipcMain.on('pty:claimViewport', (event, args: unknown) => {
|
|
if (
|
|
!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) ||
|
|
!runtime ||
|
|
!isPtyViewportClaimPayload(args)
|
|
) {
|
|
return
|
|
}
|
|
const prior = hostViewportClaimTails.get(args.id)
|
|
// Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await.
|
|
const claim = (
|
|
prior
|
|
? prior.then(
|
|
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows),
|
|
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
|
)
|
|
: runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
|
).catch(() => false)
|
|
hostViewportClaimTails.set(args.id, claim)
|
|
void claim.then(() => {
|
|
if (hostViewportClaimTails.get(args.id) === claim) {
|
|
hostViewportClaimTails.delete(args.id)
|
|
}
|
|
})
|
|
})
|
|
|
|
// Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply.
|
|
ipcMain.removeAllListeners('pty:resize')
|
|
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
|
// Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions.
|
|
if (runtime?.isResizeSuppressed()) {
|
|
return
|
|
}
|
|
// Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md.
|
|
const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile'
|
|
const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true
|
|
if (mobileOwnsResize || remoteDesktopOwnsResize) {
|
|
if (remoteDesktopOwnsResize) {
|
|
runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows)
|
|
}
|
|
return
|
|
}
|
|
const provider = tryGetProviderForPty(args.id)
|
|
if (!provider) {
|
|
return
|
|
}
|
|
const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id)
|
|
if (markedHiddenResizeOutput) {
|
|
// Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live 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 is 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 — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. 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 consumed it; 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 delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path.
|
|
ipcMain.on(
|
|
'pty:ackData',
|
|
(_event, args: { id: string; charCount?: number; processedChars?: number }) => {
|
|
lastAckReceivedAtMs = Date.now()
|
|
// Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak.
|
|
deliveryResyncUnansweredWarnLogged = false
|
|
let acknowledged = 0
|
|
if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) {
|
|
acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars))
|
|
} else {
|
|
// Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main.
|
|
const accounting = rendererDeliveryAccountingByPty.get(args.id)
|
|
const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0
|
|
acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0
|
|
}
|
|
tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged)
|
|
schedulePendingDataAfterCreditReport(acknowledged > 0)
|
|
}
|
|
)
|
|
|
|
ipcMain.on(
|
|
'pty:deliveryResyncResponse',
|
|
(_event, args: { requestId: number; processedCharsByPty: Record<string, number> }) => {
|
|
if (
|
|
deliveryResyncOutstandingRequestId === null ||
|
|
args?.requestId !== deliveryResyncOutstandingRequestId
|
|
) {
|
|
return
|
|
}
|
|
clearDeliveryResyncProbe()
|
|
deliveryResyncUnansweredWarnLogged = false
|
|
// Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs.
|
|
let creditedAny = false
|
|
for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) {
|
|
if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) {
|
|
continue
|
|
}
|
|
const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars))
|
|
if (acknowledged > 0) {
|
|
creditedAny = true
|
|
tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged)
|
|
}
|
|
}
|
|
schedulePendingDataAfterCreditReport(creditedAny)
|
|
}
|
|
)
|
|
|
|
// Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane.
|
|
ipcMain.handle(
|
|
'pty:reportRendererDeliveryState',
|
|
(_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => {
|
|
// Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain.
|
|
let creditedAny = false
|
|
for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) {
|
|
if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) {
|
|
continue
|
|
}
|
|
const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars))
|
|
if (acknowledged > 0) {
|
|
creditedAny = true
|
|
tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged)
|
|
}
|
|
}
|
|
let writtenOff: PtyDeliveryWriteOff[] = []
|
|
// Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery.
|
|
if (
|
|
args?.heal === true &&
|
|
rendererInFlightTotalChars > 0 &&
|
|
(lastAckReceivedAtMs === null ||
|
|
Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS)
|
|
) {
|
|
writtenOff = writeOffLostRendererDelivery(args)
|
|
creditedAny ||= writtenOff.length > 0
|
|
}
|
|
schedulePendingDataAfterCreditReport(creditedAny)
|
|
let inFlightPtyCount = 0
|
|
for (const accounting of rendererDeliveryAccountingByPty.values()) {
|
|
if (accounting.sentChars - accounting.ackedChars > 0) {
|
|
inFlightPtyCount++
|
|
}
|
|
}
|
|
return {
|
|
inFlightTotalChars: rendererInFlightTotalChars,
|
|
inFlightPtyCount,
|
|
msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs,
|
|
...(writtenOff.length > 0 ? { writtenOff } : {})
|
|
}
|
|
}
|
|
)
|
|
|
|
// Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate.
|
|
ipcMain.removeAllListeners('pty:rendererDispatcherReady')
|
|
ipcMain.on('pty:rendererDispatcherReady', (event) => {
|
|
// Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window.
|
|
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) {
|
|
return
|
|
}
|
|
// Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors.
|
|
if (rendererPtyDispatcherReady) {
|
|
resetRendererDeliveryAccountingForLifecycleReset()
|
|
}
|
|
// Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate.
|
|
clearDispatcherReadyWatchdog()
|
|
rendererPtyDispatcherReady = true
|
|
pendingData.reactivateBlocked()
|
|
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: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals.
|
|
if (args.active) {
|
|
if (activeRendererPtys.has(args.id)) {
|
|
return
|
|
}
|
|
activeRendererPtys.add(args.id)
|
|
} else if (!activeRendererPtys.delete(args.id)) {
|
|
return
|
|
}
|
|
invalidatePendingPtyDrainPriority(args.id)
|
|
})
|
|
|
|
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)
|
|
closeStartupQueryAuthorityForPty(args.id)
|
|
} else {
|
|
visibleRendererPtys.delete(args.id)
|
|
}
|
|
syncPtyBackgroundedDelivery(args.id, 'visibility-report')
|
|
})
|
|
|
|
ipcMain.removeAllListeners('pty:setHiddenRendererPty')
|
|
ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => {
|
|
if (typeof args.id !== 'string' || !args.id) {
|
|
return
|
|
}
|
|
mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', {
|
|
id: redactPtyIdForDiagnostics(args.id)
|
|
})
|
|
const transition = transitionHiddenRendererPtyDeliveryState(args.id, args.hidden === true)
|
|
if (args.hidden === true) {
|
|
closeStartupQueryAuthorityForPty(args.id)
|
|
// Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot.
|
|
const pending = pendingData.get(args.id)
|
|
if (pending && transition.droppable) {
|
|
pendingData.delete(args.id)
|
|
if (pending.projectionAdmissionIds) {
|
|
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
|
}
|
|
updateProducerFlowControl(args.id)
|
|
pendingOverflowMarkedPtys.delete(args.id)
|
|
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
|
|
if (drop.shouldEmitRestoreMarker) {
|
|
sendModelRestoreNeededMarker(
|
|
args.id,
|
|
'hidden-drop',
|
|
runtime?.getPtyOutputSequence(args.id)
|
|
)
|
|
}
|
|
}
|
|
if (transition.policyChanged) {
|
|
invalidatePendingPtyDrainPolicy(args.id)
|
|
}
|
|
syncPtyBackgroundedDelivery(args.id, 'gate-mark')
|
|
return
|
|
}
|
|
if (transition.policyChanged) {
|
|
invalidatePendingPtyDrainPolicy(args.id)
|
|
}
|
|
syncPtyBackgroundedDelivery(args.id, 'gate-unmark')
|
|
// Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane.
|
|
if (transition.droppedWhileHidden) {
|
|
sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id))
|
|
}
|
|
})
|
|
|
|
ipcMain.removeAllListeners('pty:terminalViewAttributes')
|
|
ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => {
|
|
// Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default.
|
|
const attributes = validateTerminalViewAttributes(args)
|
|
if (attributes) {
|
|
setTerminalViewAttributes(attributes)
|
|
}
|
|
})
|
|
|
|
ipcMain.removeAllListeners('pty:setPtyDeliveryInterest')
|
|
ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
|
|
if (typeof args.id !== 'string' || !args.id) {
|
|
return
|
|
}
|
|
// Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood.
|
|
const settings = getSettings?.()
|
|
const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings)
|
|
setRendererPtyDeliveryInterest(args.id, args.interested === true)
|
|
if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) {
|
|
invalidatePendingPtyDrainPolicy(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: clear PTY-side state (ConPTY/daemon/SSH 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 }) => {
|
|
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
|
|
// Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider.
|
|
throw new Error('Invalid PTY provider id')
|
|
}
|
|
const ownedConnectionId = ptyOwnership.get(args.id)
|
|
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
|
const connectionId = ownedConnectionId ?? parsedSshId?.connectionId
|
|
// Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742).
|
|
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
|
|
if (startupPromise) {
|
|
await startupPromise
|
|
}
|
|
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.
|
|
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
|
runtime?.onPtyExit(args.id, -1, incarnationId)
|
|
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 shutdown can leave the process alive (SSH relay grace window / local daemon); 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.
|
|
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
|
if (!providerExitObserved) {
|
|
runtime?.onPtyExit(args.id, -1, incarnationId)
|
|
rememberSyntheticKillExit(args.id)
|
|
sendPtyExitToRenderer({ id: args.id, code: -1 })
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
|
|
const deduped = new Map<string, PtyListedSession>()
|
|
const admission = new PtyProcessListAdmission()
|
|
await visitPtyProcessListingsInBatches(
|
|
registeredPtyProviders(),
|
|
({ provider, connectionId }) =>
|
|
connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []),
|
|
({ provider, connectionId }, sessions) => {
|
|
for (const rawSession of sessions) {
|
|
const session = admission.admit(rawSession)
|
|
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
|
|
ptyOwnership.set(session.id, connectionId)
|
|
deduped.set(session.id, {
|
|
id: session.id,
|
|
cwd: session.cwd,
|
|
title: session.title,
|
|
// Why: the renderer's binding map is empty during restore, so ownership is the only
|
|
// liveness evidence it has. Absence is authoritative only from a provider that
|
|
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
|
|
agentOwnership:
|
|
(session.agentSessionOwners?.length ?? 0) > 0
|
|
? 'present'
|
|
: provider.providesAgentSessionOwnerListings?.(session.id) === true
|
|
? 'absent'
|
|
: 'unknown'
|
|
})
|
|
}
|
|
}
|
|
)
|
|
return Array.from(deduped.values())
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'pty:getAuthoritativeBufferSnapshotCapabilities',
|
|
(_event, args: { ids?: unknown }) => {
|
|
const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : []
|
|
const capabilities: { id: string; authoritative: boolean | null }[] = []
|
|
const seen = new Set<string>()
|
|
for (const value of ids) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.length > 512 ||
|
|
seen.has(value)
|
|
) {
|
|
continue
|
|
}
|
|
seen.add(value)
|
|
const provider = tryGetProviderForPty(value)
|
|
// Why null is reserved for an unresolved route: the renderer never caches
|
|
// it and keeps re-arming, so a provider that merely omits the optional
|
|
// method would poll forever. Omitting it means "no authoritative
|
|
// snapshot" — the same mount-eagerly outcome null already produced, but
|
|
// cacheable. Degraded routing keeps its explicit false for the same reason.
|
|
capabilities.push({
|
|
id: value,
|
|
authoritative:
|
|
provider === undefined || provider === null
|
|
? null
|
|
: provider.canProvideAuthoritativeBufferSnapshot
|
|
? provider.canProvideAuthoritativeBufferSnapshot(value)
|
|
: false
|
|
})
|
|
}
|
|
return capabilities
|
|
}
|
|
)
|
|
|
|
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)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
|
|
inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'pty:confirmForegroundProcess',
|
|
async (_event, args: { id: string }): Promise<string | null> => {
|
|
if (!hasPtyProviderForInspection(args.id)) {
|
|
return null
|
|
}
|
|
const provider = getProviderForPty(args.id)
|
|
// Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary.
|
|
return provider.confirmForegroundProcess?.(args.id) ?? null
|
|
}
|
|
)
|
|
|
|
// Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through.
|
|
ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
|
|
try {
|
|
return await getProviderForPty(args.id).getCwd(args.id)
|
|
} catch {
|
|
return ''
|
|
}
|
|
})
|
|
|
|
// Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once.
|
|
ipcMain.handle(
|
|
'pty:getSize',
|
|
async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => {
|
|
const provider = tryGetProviderForPty(args.id)
|
|
try {
|
|
if (provider?.getAppliedSize) {
|
|
// Why: a provider-owned null means it could not verify the applied
|
|
// grid; preserve null so the renderer re-forwards instead of trusting
|
|
// the requested-size cache that may describe a dropped resize.
|
|
return await provider.getAppliedSize(args.id)
|
|
}
|
|
} catch {
|
|
// Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary.
|
|
}
|
|
return ptySizes.get(args.id) ?? null
|
|
}
|
|
)
|
|
|
|
// Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above.
|
|
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
|
|
}
|
|
const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen)
|
|
const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen)
|
|
// Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone.
|
|
pendingPtyIdBySerializerGeneration.delete(args.gen)
|
|
if (settledCurrentGeneration && ptyId) {
|
|
rendererSerializerReadiness.markReady(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)
|
|
pendingPtyIdBySerializerGeneration.delete(args.gen)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'pty:reportRendererSerializerReady',
|
|
async (_event, args: { ptyId?: unknown }): Promise<void> => {
|
|
if (
|
|
typeof args.ptyId !== 'string' ||
|
|
!args.ptyId.startsWith('remote:') ||
|
|
args.ptyId.length > 512
|
|
) {
|
|
return
|
|
}
|
|
// Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key.
|
|
rendererSerializerReadiness.markReady(args.ptyId)
|
|
}
|
|
)
|
|
}
|
|
|
|
export function registerHeadlessPtyRuntime(
|
|
runtime: OrcaRuntimeService,
|
|
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
|
|
getSettings?: () => GlobalSettings,
|
|
prepareClaudeAuth?: PrepareClaudeAuth,
|
|
store?: Store,
|
|
prepareCodexSessionResume?: PrepareCodexSessionResume
|
|
): void {
|
|
// Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals.
|
|
const headlessWindow = {
|
|
isDestroyed: () => true,
|
|
webContents: {
|
|
send: () => {},
|
|
on: () => {},
|
|
removeListener: () => {}
|
|
}
|
|
} as unknown as BrowserWindow
|
|
registerPtyHandlers(
|
|
headlessWindow,
|
|
runtime,
|
|
getSelectedCodexHomePath,
|
|
getSettings,
|
|
prepareClaudeAuth,
|
|
store,
|
|
{ prepareCodexSessionResume }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect.
|
|
*/
|
|
export function killAllPty(): void {
|
|
if (localProvider instanceof LocalPtyProvider) {
|
|
localProvider.killAll()
|
|
}
|
|
}
|