mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A) SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH worktree retained every pane forever (C1: renderer heap climbs to the V8 ceiling). SSH bytes transit local main — fact-mode watchers already cover them, and main keeps a headless model served over pty:getMainBufferSnapshot that the SSH reattach path never consulted. - isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded through both park verdicts, both selectors, watcher coverage, and the watcher start guard. Remote-runtime/fail-open/foreign/null unchanged. - Parked-SSH reveal paints from main's headless model (dimension-matched, ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a non-empty source==='headless' payload — never a blank/stale paint. - Kill switch: settings.terminalSshViewParking (default on). DESIGN.md records the approved plan and the H1 magnitude non-claim. Co-authored-by: Orca <help@stably.ai> * fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B) Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the slice-A switch off) had unlimited retention: the parking cap/TTL only ever saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's panes forever. Retention is now memory-bounded, not eligibility-bounded. - terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over hidden worktrees ordinary parking can never evict; reuses the hot-retain ranking so last-active exemption, deterministic ties, and deadline-driven rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount would fresh-spawn and orphan the live shell). - Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto (darkness for uncoverable tabs is the accepted cost); buffers captured via the sleep-flow registry before the unmount render; retention TTL added to the recheck deadlines for budget candidates only. - Verdict stays out of its own effect deps; policy test asserts idempotence and time-monotone membership (flip-loop dwell regression). - Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on). Co-authored-by: Orca <help@stably.ai> * fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C) The retention budget (slice B) must exempt worktrees holding fail-open or foreign-worktree ptys — a remount would fresh-spawn and orphan the live shell — which would leave that class unbounded again. Instead, past the same 45min retention TTL their hidden panes drop to the minimum scrollback tier (measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is gone by design, reveal restores the configured cap for future output). - terminal-hidden-scrollback-demotion.ts: module-state verdict registry (parked-watcher pattern) with content-equality notify damping; applied in the existing scrollback-rows effect in use-terminal-pane-lifecycle. - selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone. - Retention TTL wakeups now also cover exempt worktrees so demotion fires. - Kill switch: settings.terminalHiddenScrollbackDemotion (default on). Co-authored-by: Orca <help@stably.ai> * fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix) applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling it from applyReattachPayload (already inside the coordinator when a relay replay exists) deadlocks on the coordinator's tail chain. The model paint now mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate + screen, dimension-matched, escape tail last) and arms the restored-snapshot seq baseline so deferred/live chunks the snapshot covers dedupe instead of double-painting. Also falls through (no early return) so reattachPayloadApplied still latches. Adds the folder-workspace id parity unit case. Co-authored-by: Orca <help@stably.ai> * test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1) Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and reveal restores marker content at multi-viewport scrollback depth. DESIGN.md records the as-built deltas (inline paint, force-park shape, last-active floor) and the residuals so follow-ups aren't lost. Co-authored-by: Orca <help@stably.ai> * fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1) A relay restart empties the replay buffer; the reveal previously painted nothing even when main's headless model held the session. The reattach now prefetches the model snapshot when no structural replay exists (SSH-shaped ptys only) and paints it inside the coordinator; emptiness is judged on the composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an alt-screen snapshot with an empty screen frame still paints. Co-authored-by: Orca <help@stably.ai> * fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2) Per the approved contract each slice reverts behind its own switch: slice C now requires only the master terminalHiddenViewParking plus its own terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for demotion candidates even with the budget switch off. No DEFAULT_SETTINGS entries exist for sibling flags (defaults are the '!== false' optional pattern), so no explicit defaults are added. Co-authored-by: Orca <help@stably.ai> * fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3) One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park for its whole worktree, pinning co-located remote-runtime tabs forever. The worktree now force-parks while exempt tabs keep their mounted panes via a per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync, legacy render, and the overlay cold-parking hook). Ordinary parking is untouched — a worktree with an exempt tab still cannot ordinary-park. Slice C now also demotes exempt tabs' panes as soon as their worktree force-parks under the count budget (they are the only panes left mounted). Co-authored-by: Orca <help@stably.ai> * fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4) The last-active exemption means a single hidden un-parkable worktree never force-parks — and slice C previously only targeted exempt-tab worktrees, so its panes held full scrollback forever. Demotion now also covers un-parkable non-exempt worktrees past the retention TTL that are absent from the force-parked set (last-active spared, or slice B switched off). Membership stays time-monotone for fixed inputs; covered by new idempotence/monotone selector tests. Co-authored-by: Orca <help@stably.ai> * fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5) Whole-worktree background mounts (browser-automation bootstrap lease, mobile mounts, agent wakes) open a ~3s self-clearing measure window that previously deleted hiddenSince — every remount restarted the 30s hysteresis and the 45min retention TTL, so a periodically re-mounted force-parked worktree never re-parked. The measure window still pauses parking/eviction verdicts (all selectors skip measuring candidates); only the clock survives, so the prior verdict resumes as soon as the window closes. Visible and portal-holding worktrees still reset the clock. Co-authored-by: Orca <help@stably.ai> * test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a) Pad the session with ~180KB of output after the numbered markers so the earliest marker falls outside the relay's 100KiB rolling replay buffer while staying inside main's ~5k-row headless model; asserting marker_1 after reveal now proves the headless-model paint rather than passing under the relay fallback. Co-authored-by: Orca <help@stably.ai> * docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7) One contract matching the code: status IMPLEMENTED around force-park (not the unmount proposal), real kill-switch names with coupling + revert matrices, the true retention-floor formula with measured per-pane and demotion numbers, an explicit when-OOM-is-still-possible paragraph naming the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock constraint inside the slice-A section, stable-signal phrasing instead of a capability latch, fail-open AND foreign-worktree exemption class, verified cites, and a planned/landed/follow-up test matrix. Co-authored-by: Orca <help@stably.ai> * fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8) isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty — while the coverage veto that makes a worktree a retention candidate walks every pane. A split tab whose second leaf held an unrestorable pty therefore failed coverage (→ force-park target) yet looked exempt-free, so force-park unmounted it and orphaned the live shell. The exemption now resolves panes through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in the union for the no-layout/no-capture case. Also from the same review round: - force-park's capture passes includeLocalBuffers:false like every other shutdownBufferCaptures caller; it was serializing up to 512KB/pane of scrollback into the store inside a fix meant to bound renderer heap. - Terminal.tsx unmount resets the scrollback-demotion registry — module state with no reset path, read by a pane effect that runs before the host effect that would clear it, so a stale verdict trimmed restore replays. - memoize watcher coverage per tab within the parking pass; the retention candidates re-asked it for every mounted worktree, not just the parked few. * docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body Co-authored-by: Orca <help@stably.ai> * fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2) pendingSideEffects grew without bound under background timer throttling (~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with oldest-first eviction: titles drop (last-wins), a pending bell latches onto the next survivor, agent-status payloads collapse onto the survivor keeping the newest 16 (last-wins store state, KB-scale strings). Co-authored-by: Orca <help@stably.ai> * fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up) Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so OSC 133;D and Command Code scrape signals went dark while parked. New parked-terminal-command-status.ts ports the store-level subset: git-UI nudge on every command finish, same-turn status-row drop for SSH PTYs (exact mounted-path parity — the foreground tracker refuses SSH ids), and the Command Code working seed / 1500ms done settle. Byte mode scans the same shared parsers for authority-off parity. Local-PTY status drops stay with the mounted pane: they need pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a real agent exit. Co-authored-by: Orca <help@stably.ai> * test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b) ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config → getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer only) so a spec can shrink the force-park budget to 1. The Docker-gated spec opens two remote worktrees on one relay target (second pre-seeded remote repo), disables terminalSshViewParking to make both un-parkable, hides both behind the local context, and proves the older one force-parks while the last-active exemption spares the newest; re-activating the evicted worktree restores the marker tail via relay replay. Co-authored-by: Orca <help@stably.ai> * test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane) The first draft added a second remote repo mid-session, whose pane pty spawn misroutes to the local daemon with the remote cwd (pre-existing multi-repo issue, reproducible without any retention override — a seeded local repo plus one remote repo shows the same misroute). The spec now budgets across three worktrees of the ONE connected repo, created through the product createWorktree path (an external git-worktree-add only lands as a detected worktree needing adoption) and polled through the relay's transient post-connect reconnect window. Verified green on the local Docker lane in 20.8s. Co-authored-by: Orca <help@stably.ai> * fix(terminal): prevent remount thrashing during post-measure cool-down ( Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a background-measure window (so TTL/ranking stay honest), but re-park waits for a full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down, every ~3s measure lease on a past-deadline worktree thrashes remount/reattach. Core changes: - Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates until cool-down expires. - Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts (used by SSH reattach + daemon restore paths). - Add SSH model snapshot timeout (750ms) with fallback to relay replay. - Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts; add cool-down deadline to scheduling. - useTerminalTabColdParking: implement matching measure-clock contract with per-tab cool-down gate to keep tab deadlines synced with worktree retention clock. - Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted worktrees (pane births during demotion must take the demoted tier at create). - Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget, terminalHiddenScrollbackDemotion. * fix(terminal): detect Command Code completion in parked mid-turn panes Seed the byte watcher with in-flight turn state from agent status: the watcher is recreated per park cycle with no startup command to arm it, and the banner scrolled away before parking. Also memoize eviction-exempt checks and use SSH PTY ID builder in tests. * fix(terminal): flush pending command-code settles on reveal remount When a parked pane reveals mid-Command Code turn, the new detector cannot re-observe the already-passed idle composer. Cancelling the settle leaves the row stranded at 'working', so dispose now flushes the pending settle instead. Extract readInFlightCommandCodeTurn to shared space and seed detectors with in-flight turns so remounts complete mid-flight commands. Also memoize SSH model probes to prevent double timeouts on reattach. * fix(terminal): remove scrollback demotion (C1 slice C) The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic. * test(terminal): assert bounded probe during stalled reveal Add assertion to verify that a stalled reveal operation makes exactly one `getMainBufferSnapshot` call, ensuring retry logic doesn't introduce redundant probes that would extend the timeout window before relay fallback. * fix(terminal): implement C1 retention budget for hidden parked worktrees Addresses OOM regressions in hidden parked terminals by force-evicting worktrees past a retention budget: at most 12 mounted while hidden, none past 45 minutes (absolute, not exempted by last-active). Eviction is least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep their panes to avoid orphaning shells; worktrees are force-parked even if they contain exempts, and their buffers released elsewhere. SSH/remote worktrees serialize buffers pre-eviction for reveal; local worktrees keep daemon snapshots. Command Code's done-settle window is transferred across park/reveal boundaries so the row cannot strand at 'working'. Model probe on SSH reattach is scoped to park-reveal only, not ordinary reconnects. Includes new E2E suite proving the budget actually releases memory. * memoize eviction-exempt terminal tabs to avoid redundant store reads Each tab's exemption check re-reads the store and walks the layout tree. Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs for a worktree in a single pass, then memoize the result in Terminal.tsx and useTerminalTabColdParking. This prevents O(n) store reads when checking exemptions across multiple tabs and ensures the set remains stable across unrelated re-renders. * refactor: reformat hidden-worktree retention comments Reflow to 80-character lines and remove internal ticket references (C1, C1 slice C). * fix(lint): split overlay slot and eviction-exempt tabs under max-lines Static analysis failed because TerminalPaneOverlayLayer (401) and terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the slot component and eviction-exempt helpers into dedicated modules. * test(terminal): stabilize retention budget e2e control arm Stage un-parkable remote pty ids only after both worktrees are hidden, and keep re-staging during the control-arm poll so a late updateTabPtyId cannot flip the decoy back to park-restorable and ordinary-park it before budget engages. * test(terminal): pin retention e2e decoy to a mounted pane snapshot Use the active pane-identity snapshot for the decoy tab instead of all worktree tabs, and re-assert un-parkable ids after the control-arm hold so a deferred/empty tab id cannot fail the budget-off mounted-count check. * fix: memoize terminal eviction exemptions on layout leaf PTYs Splits add leaf panes to the layout store without changing the tabs array. A memo keyed only on tabs misses this change, leaving new panes unexempted for unmount. Include layout leaf PTYs in the exemption memo key so it recalculates when splits occur or PTYs are re-minted. --------- Co-authored-by: Orca <help@stably.ai>
5980 lines
250 KiB
TypeScript
5980 lines
250 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 { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
|
|
import { isPwshAvailable } from '../pwsh'
|
|
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
|
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
|
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
|
import { inspectPtyProviderProcess } 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 } 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 {
|
|
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 {
|
|
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 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>()
|
|
|
|
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 GetSelectedCodexHomePath = (
|
|
target?: CodexAccountSelectionTarget,
|
|
launchEnv?: NodeJS.ProcessEnv,
|
|
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
|
) => 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
|
|
}
|
|
|
|
// 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
|
|
target: CodexAccountSelectionTarget
|
|
settings: GlobalSettings | undefined
|
|
}): void {
|
|
if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) {
|
|
return
|
|
}
|
|
const record = args.settings
|
|
? resolveCodexPaneLaunchAccount({
|
|
pinnedByResume: args.pinnedByResume,
|
|
launchCodexHomePath: args.launchCodexHomePath,
|
|
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 piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
|
|
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')
|
|
if (piAgentKind === 'pi') {
|
|
const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi')
|
|
Object.assign(baseEnv, piEnv)
|
|
exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv)
|
|
}
|
|
|
|
if (shouldPrepareOmpShadow) {
|
|
const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp')
|
|
Object.assign(baseEnv, ompEnv)
|
|
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
|
|
}
|
|
} else {
|
|
// Why: 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)
|
|
}
|
|
|
|
/** Remove an SSH PTY provider when a connection is closed. */
|
|
export function unregisterSshPtyProvider(connectionId: string): void {
|
|
sshProviders.delete(connectionId)
|
|
}
|
|
|
|
/** Get the SSH PTY provider for a connection (for dispose on cleanup). */
|
|
export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined {
|
|
return sshProviders.get(connectionId)
|
|
}
|
|
|
|
/** Get the installed PTY provider (for direct access in tests/runtime).
|
|
* 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
|
|
|
|
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.removeAllListeners('pty:getAuthoritativeBufferSnapshotCapabilitiesSync')
|
|
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
|
|
}
|
|
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?.())
|
|
)
|
|
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
|
|
const rendererExitingPtyIds = new Set<string>()
|
|
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)
|
|
})
|
|
|
|
function updateProducerFlowControl(id: string): void {
|
|
if (!PRODUCER_FLOW_CONTROL_ENABLED) {
|
|
return
|
|
}
|
|
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 {
|
|
pendingData.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
|
|
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)
|
|
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) {
|
|
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): 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)
|
|
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 false
|
|
}
|
|
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 true
|
|
}
|
|
|
|
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
|
|
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 appendPendingPtyData(
|
|
id: string,
|
|
existing: PendingPtyData | undefined,
|
|
data: string,
|
|
startSeq: number | undefined,
|
|
preservesSeq: boolean,
|
|
containsBackgroundOutput: boolean,
|
|
rawLength = data.length,
|
|
transformed = false
|
|
): 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) {
|
|
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 nextContainsBackgroundOutput =
|
|
existing?.containsBackgroundOutput === true || containsBackgroundOutput
|
|
if (!existing) {
|
|
return dropOversizedPendingPtyData(id, {
|
|
data,
|
|
...(typeof startSeq === 'number' ? { startSeq } : {}),
|
|
...(rawLength !== data.length ? { rawLength } : {}),
|
|
...(transformed ? { transformed: true } : {}),
|
|
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
|
})
|
|
}
|
|
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 } : {})
|
|
}
|
|
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)
|
|
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
|
|
})
|
|
) {
|
|
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)
|
|
if (remaining) {
|
|
const nextPending: PendingPtyData = { data: remaining }
|
|
if (typeof pending.startSeq === 'number') {
|
|
nextPending.startSeq = pending.startSeq + chunk.length
|
|
}
|
|
if (pending.containsBackgroundOutput === true) {
|
|
nextPending.containsBackgroundOutput = true
|
|
}
|
|
pendingData.replaceWithRemainder(selection, nextPending)
|
|
} else {
|
|
pendingData.remove(selection)
|
|
pendingOverflowMarkedPtys.delete(id)
|
|
}
|
|
updateProducerFlowControl(id)
|
|
if (
|
|
!sendPtyDataToRenderer(
|
|
id,
|
|
makePtyDataPayload(
|
|
id,
|
|
chunk,
|
|
pending.startSeq,
|
|
pending.containsBackgroundOutput,
|
|
pending.rawLength,
|
|
pending.transformed
|
|
)
|
|
)
|
|
) {
|
|
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 sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
|
if (mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
if (rendererExitingPtyIds.has(payload.id)) {
|
|
return
|
|
}
|
|
rendererExitingPtyIds.add(payload.id)
|
|
try {
|
|
const hadReleasableRendererCredit = 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
|
|
})
|
|
} else {
|
|
sendPtyDataToRenderer(
|
|
payload.id,
|
|
makePtyDataPayload(
|
|
payload.id,
|
|
remaining.data,
|
|
remaining.startSeq,
|
|
remaining.containsBackgroundOutput,
|
|
remaining.rawLength,
|
|
remaining.transformed
|
|
)
|
|
)
|
|
}
|
|
}
|
|
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
|
|
producerFlowControl.release(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 } : {})
|
|
})
|
|
} finally {
|
|
rendererExitingPtyIds.delete(payload.id)
|
|
}
|
|
}
|
|
|
|
function sendPtySpawnedToRenderer(id: string): void {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('pty:spawned', { id })
|
|
}
|
|
}
|
|
|
|
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).
|
|
// Typed at the call site (not on the capped IPtyProvider): only respawnable
|
|
// endpoints like the daemon adapter implement it.
|
|
const writeUnavailableSource = localProvider as {
|
|
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
|
|
}
|
|
localWriteUnavailableUnsub =
|
|
writeUnavailableSource.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)
|
|
const rendererData = payload.data
|
|
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
|
const startSeq =
|
|
typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
|
if (mainWindow.isDestroyed()) {
|
|
// Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration.
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
producerFlowControl.releaseAll()
|
|
clearDeliveryResyncProbe()
|
|
clearPendingPtyData()
|
|
pendingOverflowMarkedPtys.clear()
|
|
rendererDeliveryAccountingByPty.clear()
|
|
rendererInFlightTotalChars = 0
|
|
clearDispatcherReadyWatchdog()
|
|
return
|
|
}
|
|
if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) {
|
|
return
|
|
}
|
|
const settings = getSettings?.()
|
|
// Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot.
|
|
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
|
|
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
|
|
warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length)
|
|
if (drop.shouldEmitRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
|
}
|
|
return
|
|
}
|
|
if (rendererData.length === 0 && !payload.transformed) {
|
|
return
|
|
}
|
|
const containsBackgroundOutput =
|
|
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
|
if (containsBackgroundOutput) {
|
|
markHiddenRendererResizeOutputDelivered(payload.id)
|
|
}
|
|
const existing = pendingData.get(payload.id)
|
|
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
|
const pending = appendPendingPtyData(
|
|
payload.id,
|
|
existing,
|
|
rendererData,
|
|
startSeq,
|
|
preservesSeq,
|
|
containsBackgroundOutput,
|
|
rawLength,
|
|
payload.transformed === true
|
|
)
|
|
const shouldEmitPendingCapRestoreMarker =
|
|
pending.droppedOutput === true &&
|
|
!overflowMarkedBeforeAppend &&
|
|
pendingOverflowMarkedPtys.has(payload.id)
|
|
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
|
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
|
payload.id,
|
|
nextData,
|
|
performance.now()
|
|
)
|
|
// Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate.
|
|
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
|
// Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway.
|
|
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)
|
|
// Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency.
|
|
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 } : {})
|
|
})
|
|
} finally {
|
|
updateProducerFlowControl(payload.id)
|
|
}
|
|
return
|
|
}
|
|
setPendingPtyData(payload.id, pending)
|
|
if (shouldEmitPendingCapRestoreMarker) {
|
|
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
|
}
|
|
updateProducerFlowControl(payload.id)
|
|
// Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable.
|
|
if (
|
|
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
|
) {
|
|
requestDeliveryResyncForGatedPty()
|
|
}
|
|
if (!flushTimer) {
|
|
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
|
}
|
|
})
|
|
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 codexSelectionTarget = getCodexSelectionTargetForPty(
|
|
daemonShellOverride,
|
|
cwd,
|
|
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
|
)
|
|
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 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 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 }
|
|
}
|
|
const selectedCodexHomePath = isDaemonHostSpawn
|
|
? getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
codexResumeHome
|
|
? codexResumeHome.codexHomePath
|
|
: (getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
|
workspacePath: cwd,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
|
|
}) ?? null)
|
|
)
|
|
: null
|
|
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' ? codexSelectionTarget.wslDistro : null,
|
|
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
|
networkProxySettings: getSettings?.(),
|
|
deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true
|
|
})
|
|
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 (!isDaemonHostSpawn && codexResumeHome) {
|
|
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
|
|
}
|
|
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 (codexResumeHome?.codexHomePath) {
|
|
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 =
|
|
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
|
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: Boolean(codexResumeHome),
|
|
launchCodexHomePath: selectedCodexHomePath,
|
|
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 () => {
|
|
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 initialSelectionTarget = getCodexSelectionTargetForPty(
|
|
initialShellOverride,
|
|
cwd,
|
|
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
|
)
|
|
const claudeAuth =
|
|
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
|
spawnTiming.mark('auth')
|
|
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
|
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
|
}
|
|
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
|
throw new Error(
|
|
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
|
)
|
|
}
|
|
// Why: the daemon-backed provider 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 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 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,
|
|
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
|
)
|
|
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
|
|
const selectedCodexHomePath = isDaemonHostSpawn
|
|
? getCompatibleSelectedCodexHomePath(
|
|
codexSelectionTarget,
|
|
codexResumeHome
|
|
? codexResumeHome.codexHomePath
|
|
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
|
workspacePath: cwd,
|
|
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
|
|
}) ?? null)
|
|
)
|
|
: null
|
|
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' ? codexSelectionTarget.wslDistro : null,
|
|
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
|
networkProxySettings: getSettings?.(),
|
|
deferGitConfigGuardToDaemon:
|
|
provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true
|
|
})
|
|
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 (codexResumeHome?.codexHomePath) {
|
|
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 (!isDaemonHostSpawn && codexResumeHome) {
|
|
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
|
|
}
|
|
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 =
|
|
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
|
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: Boolean(codexResumeHome),
|
|
launchCodexHomePath: selectedCodexHomePath,
|
|
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)
|
|
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.on(
|
|
'pty:getAuthoritativeBufferSnapshotCapabilitiesSync',
|
|
(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: degraded routing mixes preserved daemons with an in-process fallback; keep all panes mounted rather than guess ownership.
|
|
capabilities.push({
|
|
id: value,
|
|
authoritative: provider?.canProvideAuthoritativeBufferSnapshot
|
|
? provider.canProvideAuthoritativeBufferSnapshot(value)
|
|
: provider && routesFreshSpawnsToLocalProvider(provider)
|
|
? false
|
|
: null
|
|
})
|
|
}
|
|
// Why: cold deferral runs during render before hidden panes mount; this in-memory route lookup lets legacy PTYs mount in that pass.
|
|
event.returnValue = 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 }) =>
|
|
inspectPtyProviderProcess(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()
|
|
}
|
|
}
|