Split local PTY provider responsibilities

This commit is contained in:
Neil
2026-08-29 07:01:38 -07:00
parent 64c9dea982
commit 277fce6acd
21 changed files with 2109 additions and 1472 deletions
-1
View File
@@ -17,7 +17,6 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -0,0 +1,123 @@
import { win32 as pathWin32 } from 'node:path'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion'
import { dropInheritedOrcaFishHistory } from '../fish-history-session'
import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import {
POWERLEVEL10K_WIZARD_DISABLE_ENV,
seedPowerlevel10kWizardEnv
} from '../pty/powerlevel10k-wizard-env'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { selectShellStartupFeatures } from '../shell-startup-features'
import {
injectHistoryEnv,
injectWslFishHistoryEnv,
logHistoryInjection,
type HistoryInjectionResult
} from '../terminal-history'
import { addWslEnvKeys } from '../wsl-env'
import { dropInheritedOrcaHistFile } from '../worktree-history-file-path'
import { promoteAgentTeamsShimPath } from './local-pty-launch-helpers'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import { getShellLaunchConfig } from './local-pty-shell-ready'
import { finalizeWindowsLocalPtySpawnEnvironment } from './local-pty-windows-spawn-environment'
import type { PtySpawnOptions } from './types'
export function finalizeLocalPtySpawnEnvironment(args: {
spawn: PtySpawnOptions
getOptions: () => LocalPtyProviderOptions
plan: LocalPtyLaunchPlan
env: Record<string, string>
}): HistoryInjectionResult | null {
const { spawn, getOptions, plan, env } = args
if (process.platform === 'win32') {
finalizeWindowsLocalPtySpawnEnvironment({ spawn, plan, env })
}
seedPowerlevel10kWizardEnv(env, { envToDelete: spawn.envToDelete })
if (
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
process.platform === 'win32' &&
pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe'
) {
addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
const requestedEnv = spawn.env
expandWindowsPathEnvironmentVariables(env)
promoteAgentTeamsShimPath(
env,
requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined
)
// Why: raw requested PATH promotion runs after the host-env scrub.
stripLegacyTerminalShimEnv(env, process.platform)
// Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind.
dropIncoherentCondaActivationEnv(env, process.platform)
// Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10).
const worktreeId = spawn.worktreeId
const historyEnabled = worktreeId && (getOptions().isHistoryEnabled?.() ?? true)
// Effective shell for history injection: WSL's outer exe is wsl.exe but the inner login shell is bash.
const isWslTerminal =
Boolean(plan.wslInfo || plan.worktreeWslContext || plan.preferredWslContext) ||
pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe'
const effectiveShellPath = isWslTerminal ? 'bash' : plan.shellPath
let historyResult: ReturnType<typeof injectHistoryEnv> | null = null
if (historyEnabled) {
historyResult = injectHistoryEnv(env, worktreeId, effectiveShellPath, plan.cwd, {
wslDistro: plan.launchWslDistro
})
if (isWslTerminal && plan.launchWslDistro) {
injectWslFishHistoryEnv(env, worktreeId, plan.launchWslDistro)
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
}
logHistoryInjection(worktreeId, historyResult)
} else {
// Why: injectHistoryEnv is what normally clears it, so when history is off
// an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146.
delete env.ORCA_HISTFILE
// Same for an exported `fish_history` from the fish pane that launched this
// Orca: history off means fish's own default, not another worktree's file.
dropInheritedOrcaFishHistory(env)
// And for an exported HISTFILE: history off means the shell's own default,
// not the history file of the worktree this Orca was launched from.
dropInheritedOrcaHistFile(env)
}
if (!plan.wslInfo && process.platform !== 'win32') {
// Why after history injection: the wrapper is what repairs a worktree
// HISTFILE that the system zshrc clobbers, so the decision to wrap has to
// see whether this spawn actually injected one.
const isCodexStartupCommand = plan.startupAgentRecognition?.agent === 'codex'
// Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed.
const waitsForShellReady =
Boolean(spawn.command) &&
(!isCodexStartupCommand ||
shouldUseShellReadyStartupDelivery({
command: spawn.command as string,
startupCommandDelivery: spawn.startupCommandDelivery
}))
// Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch
// config below may name features for this shell.
delete env.ORCA_SHELL_FEATURES
plan.getFallbackShellReadyConfig = (shell) =>
getShellLaunchConfig(
shell,
selectShellStartupFeatures({
shellPath: shell,
env,
hasStartupCommand: Boolean(spawn.command),
waitsForShellReady,
// Why identical: the identity marker exists so the readiness
// handshake can bind output to the right shell PID.
emitsStartupIdentity: waitsForShellReady
})
)
const shellLaunch = plan.getFallbackShellReadyConfig(plan.shellPath)
Object.assign(env, shellLaunch.env)
plan.shellArgs = shellLaunch.args ?? plan.shellArgs
plan.shellReadyLaunch = spawn.command ? shellLaunch : null
plan.primaryLaunchEnvKeys = Object.keys(shellLaunch.env)
}
return historyResult
}
@@ -0,0 +1,198 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import {
confirmShellForegroundProcess,
resolveAgentForegroundProcessWithAvailability
} from './agent-foreground-process'
import { resolveForegroundFallbackProcess } from './local-pty-launch-helpers'
import {
ptyAgentForegroundContextPaths,
ptyLastRecognizedForeground,
ptyProcesses,
ptyShellName
} from './local-pty-provider-state'
import { resolveStableForegroundProcess } from './stable-foreground-process'
import {
canRevalidateCachedAgentWithoutScan,
judgeCachedAgentJobEvidence
} from './windows-cached-agent-revalidation'
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
export async function hasLocalPtyChildProcesses(id: string): Promise<boolean> {
const proc = ptyProcesses.get(id)
if (!proc) {
return false
}
try {
const foreground = proc.process
const shell = ptyShellName.get(id)
if (!shell) {
return true
}
return foreground !== shell
} catch {
return false
}
}
export async function getLocalPtyForegroundProcess(id: string): Promise<string | null> {
const proc = ptyProcesses.get(id)
if (!proc) {
ptyLastRecognizedForeground.delete(id)
return null
}
const fallbackProcess = resolveForegroundFallbackProcess(
proc.process || null,
ptyShellName.get(id)
)
const cachedEntry = ptyLastRecognizedForeground.get(id)
const cachedAgent = cachedEntry?.name ?? null
let paneMembershipUnavailable = false
let cachedAgentAliveInJob = false
// Why: job membership preserves a live cached agent without the whole-table
// scan (incomplete under Windows load). Job, not console: this asks "is
// anything besides the shell alive?", which needs no console attachment and
// so needs no forked helper (#10857).
if (
process.platform === 'win32' &&
canRevalidateCachedAgentWithoutScan(cachedAgent, fallbackProcess)
) {
try {
const paneProcessIds = readWindowsPtyJobProcessIds(proc)
if (ptyProcesses.get(id) !== proc) {
return null
}
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: paneProcessIds,
jobSupported: isWindowsPtyJobReadable(),
shellPid: proc.pid,
anchorProcessId: cachedEntry?.pid ?? null,
identityAgeMs: Date.now() - (cachedEntry?.at ?? 0)
})
if (verdict === 'confirmed' || verdict === 'unproven') {
return cachedAgent
}
if (verdict === 'exited') {
// The shell stands alone in a complete, inescapable job list: no
// successor is possible, so the identity retires before the scan.
ptyLastRecognizedForeground.delete(id)
} else if (verdict === 'anchor-exited' && cachedEntry) {
// The recognized process died but another member remains -- a
// leftover, or a restarted successor. Keep the name as unanchored,
// age-bounded evidence and let this cycle's scan decide: deleting
// here made a degraded scan read a mid-restart agent as an exit.
ptyLastRecognizedForeground.set(id, { ...cachedEntry, pid: null })
}
cachedAgentAliveInJob = verdict === 'recheck'
paneMembershipUnavailable = verdict === 'unavailable'
} catch {
paneMembershipUnavailable = true
}
}
try {
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths: ptyAgentForegroundContextPaths.get(id),
...(cachedEntry?.pid != null
? { anchorProcessId: cachedEntry.pid, anchorProcessName: cachedEntry.name }
: {})
}
)
// Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id.
if (ptyProcesses.get(id) !== proc) {
return null
}
// Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead.
const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null
const resolvedAgent = resolution.processName
? recognizeAgentProcessFromCommandLine(resolution.processName)
: null
// A recycled anchor pid keeps job membership truthful but the identity
// dead; the scan proving the pid now runs a non-agent settles it.
const anchorContradicted = resolution.anchorPidForeign === true
// Why: incomplete snapshot + unavailable job read isn't exit proof; and an
// anchor pid still alive in the job outranks a snapshot that lost its row.
const stableResolution =
(paneMembershipUnavailable || cachedAgentAliveInJob) &&
!anchorContradicted &&
resolvedAgent === null
? { ...resolution, available: false }
: resolution
const stable = resolveStableForegroundProcess(stableResolution, lastRecognizedAgent)
if (stable.lastRecognizedAgent && stableResolution.available) {
// Only a positive recognition restarts the age bound.
ptyLastRecognizedForeground.set(id, {
name: stable.lastRecognizedAgent,
pid:
stable.lastRecognizedAgent === resolution.processName
? (resolution.processId ?? null)
: null,
at: Date.now()
})
} else if (stable.lastRecognizedAgent && cachedAgentAliveInJob && !anchorContradicted) {
// The anchor pid in the job is proof of life; restamp so the
// short-circuit resumes instead of scanning on every call.
const entry = ptyLastRecognizedForeground.get(id)
if (entry) {
ptyLastRecognizedForeground.set(id, { ...entry, at: Date.now() })
}
} else if (!stable.lastRecognizedAgent) {
ptyLastRecognizedForeground.delete(id)
}
return stable.processName
} catch {
if (ptyProcesses.get(id) !== proc) {
return null
}
// Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit).
return ptyLastRecognizedForeground.get(id)?.name ?? null
}
}
export async function confirmLocalPtyForegroundProcess(id: string): Promise<string | null> {
const proc = ptyProcesses.get(id)
if (!proc) {
return null
}
try {
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)),
{
contextPaths: ptyAgentForegroundContextPaths.get(id),
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
)
// Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session.
if (ptyProcesses.get(id) !== proc) {
return null
}
return resolution.available ? resolution.processName : null
} catch {
return null
}
}
export async function confirmLocalPtyShellForeground(id: string): Promise<boolean> {
const proc = ptyProcesses.get(id)
if (!proc) {
return false
}
const confirmed = await confirmShellForegroundProcess(
proc.pid,
ptyShellName.get(id),
process.platform === 'win32'
? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc) }
: {}
)
return ptyProcesses.get(id) === proc && confirmed
}
+247
View File
@@ -0,0 +1,247 @@
import { win32 as pathWin32 } from 'node:path'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveWindowsGitBashShellPath } from '../git-bash'
import { getDefaultWslDistro, parseWslPath } from '../wsl'
import {
getDefaultCwd,
getWslContextFromPreferredDistro,
getWslContextFromWorktreeId
} from './local-pty-launch-helpers'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import type { getShellLaunchConfig } from './local-pty-shell-ready'
import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory } from './local-pty-utils'
import {
resolveEffectiveWindowsPowerShell,
shouldProbeWindowsPowerShellAvailability,
type WindowsPowerShellShellFamily
} from './windows-powershell'
import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import { assertSafeAgentStartupCwd } from './pty-default-cwd'
import type { PtySpawnOptions } from './types'
type WslLaunchContext = { distro: string; treatPosixCwdAsWsl: true }
type LocalPtyLaunchSeed = {
args: PtySpawnOptions
startupAgentRecognition: ReturnType<typeof recognizeAgentProcessFromCommandLine>
defaultCwd: string
cwd: string
wslInfo: ReturnType<typeof parseWslPath>
worktreeWslContext: WslLaunchContext | undefined
preferredWslContext: WslLaunchContext | undefined
launchWslContext: WslLaunchContext | undefined
}
export type LocalPtyLaunchPlan = {
startupAgentRecognition: ReturnType<typeof recognizeAgentProcessFromCommandLine>
defaultCwd: string
cwd: string
wslInfo: ReturnType<typeof parseWslPath>
worktreeWslContext: WslLaunchContext | undefined
preferredWslContext: WslLaunchContext | undefined
launchWslContext: WslLaunchContext | undefined
shellPath: string
shellArgs: string[]
effectiveCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
windowsFallbackAttempts: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
shellReadyLaunch: ReturnType<typeof getShellLaunchConfig> | null
getFallbackShellReadyConfig:
| ((shell: string) => ReturnType<typeof getShellLaunchConfig>)
| undefined
// Why hoisted: a fallback shell must drop the primary's launch env, and
// re-deriving the key names would re-run wrapper generation.
primaryLaunchEnvKeys: string[]
isWslShell: boolean
launchWslDistro: string | null
}
export class DeferredLocalPtyLaunchPlan {
constructor(
readonly availability: boolean | Promise<boolean>,
readonly finish: (available: boolean) => LocalPtyLaunchPlan
) {}
}
function finalizeLocalPtyLaunchPlan(
seed: LocalPtyLaunchSeed,
shell: {
shellPath: string
shellArgs: string[]
effectiveCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs?: boolean
windowsFallbackAttempts?: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
}
): LocalPtyLaunchPlan {
ensureNodePtySpawnHelperExecutable()
if (seed.args.prevalidatedCwd !== shell.validationCwd) {
validateWorkingDirectory(shell.validationCwd)
}
const isWslShell =
Boolean(seed.wslInfo) || pathWin32.basename(shell.shellPath).toLowerCase() === 'wsl.exe'
return {
startupAgentRecognition: seed.startupAgentRecognition,
defaultCwd: seed.defaultCwd,
cwd: seed.cwd,
wslInfo: seed.wslInfo,
worktreeWslContext: seed.worktreeWslContext,
preferredWslContext: seed.preferredWslContext,
launchWslContext: seed.launchWslContext,
shellPath: shell.shellPath,
shellArgs: shell.shellArgs,
effectiveCwd: shell.effectiveCwd,
validationCwd: shell.validationCwd,
startupCommandDeliveredInShellArgs: shell.startupCommandDeliveredInShellArgs ?? false,
windowsFallbackAttempts: shell.windowsFallbackAttempts ?? [],
shellReadyLaunch: null,
getFallbackShellReadyConfig: undefined,
primaryLaunchEnvKeys: [],
isWslShell,
launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null
}
}
function createWindowsLocalPtyLaunchPlan(
seed: LocalPtyLaunchSeed,
getOptions: () => LocalPtyProviderOptions
): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan {
const { args, cwd, defaultCwd, worktreeWslContext } = seed
// Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting.
const requestedShellFamily =
args.shellOverride ||
getOptions().getWindowsShell?.() ||
process.env.COMSPEC ||
'powershell.exe'
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
if (!seed.launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') {
seed.launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro())
}
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily)
// Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe.
const powerShellImplementation = getOptions().getWindowsPowerShellImplementation?.()
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
? normalizedShellFamily
: normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe'
? normalizedShellFamily
: undefined
const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({
shellFamily: resolvedShellFamily,
implementation: powerShellImplementation
})
const shouldResolvePowerShellFamily =
powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily
const finish = (pwshAvailable: boolean): LocalPtyLaunchPlan => {
let shellPath: string
if (resolvedGitBashPath) {
shellPath = resolvedGitBashPath
} else if (shellFamily === WINDOWS_GIT_BASH_SHELL) {
shellPath = 'powershell.exe'
} else {
shellPath = shouldResolvePowerShellFamily
? (resolveEffectiveWindowsPowerShell({
shellFamily: resolvedShellFamily,
implementation: powerShellImplementation,
pwshAvailable
}) ?? shellFamily)
: shellFamily
}
// Why: bare `pwsh.exe` resolves to the Store App Execution Alias stub whose spawn fails (code 5); use an absolute exe + cmd.exe fallback.
const windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd,
defaultCwd,
wslContext: seed.launchWslContext,
startupCommand: args.command
})
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
return finalizeLocalPtyLaunchPlan(seed, {
shellPath: primaryAttempt.shellPath,
shellArgs: primaryAttempt.shellArgs,
effectiveCwd: primaryAttempt.effectiveCwd,
validationCwd: primaryAttempt.validationCwd,
startupCommandDeliveredInShellArgs: primaryAttempt.startupCommandDeliveredInShellArgs,
windowsFallbackAttempts
})
}
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
cwd,
defaultCwd,
seed.launchWslContext,
args.command
)
return finalizeLocalPtyLaunchPlan(seed, {
shellPath,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd,
startupCommandDeliveredInShellArgs: resolved.startupCommandDeliveredInShellArgs === true,
windowsFallbackAttempts
})
}
return shouldProbePwsh
? new DeferredLocalPtyLaunchPlan(getOptions().pwshAvailable?.() ?? false, finish)
: finish(false)
}
export function createLocalPtyLaunchPlan(
args: PtySpawnOptions,
getOptions: () => LocalPtyProviderOptions
): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan {
const startupAgentRecognition = args.command
? recognizeAgentProcessFromCommandLine(args.command)
: null
const defaultCwd = getDefaultCwd()
const cwd = args.cwd || defaultCwd
// Why: gate on the effective cwd, not raw args.cwd — an omitted cwd becomes a safe default and must not be rejected as root-like.
if (args.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(cwd, args.command)
}
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
const worktreeWslContext =
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
const preferredWslContext =
process.platform === 'win32'
? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro)
: undefined
const seed: LocalPtyLaunchSeed = {
args,
startupAgentRecognition,
defaultCwd,
cwd,
wslInfo,
worktreeWslContext,
preferredWslContext,
launchWslContext:
wslInfo !== null
? getWslContextFromPreferredDistro(wslInfo.distro)
: (worktreeWslContext ?? preferredWslContext)
}
if (wslInfo) {
const shellPath = 'wsl.exe'
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd)
return finalizeLocalPtyLaunchPlan(seed, {
shellPath,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd
})
}
if (process.platform === 'win32') {
return createWindowsLocalPtyLaunchPlan(seed, getOptions)
}
const shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh'
return finalizeLocalPtyLaunchPlan(seed, {
shellPath,
shellArgs: ['-l'],
effectiveCwd: cwd,
validationCwd: cwd
})
}
@@ -191,6 +191,22 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('re-reads buildSpawnEnv after a reentrant configuration change', async () => {
const initialBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
provider.configure({
get buildSpawnEnv() {
provider.configure({ buildSpawnEnv: configuredBuildSpawnEnv })
return initialBuildSpawnEnv
}
})
await provider.spawn({ cols: 80, rows: 24 })
expect(initialBuildSpawnEnv).not.toHaveBeenCalled()
expect(configuredBuildSpawnEnv).toHaveBeenCalledOnce()
})
it.each([
// fish EXPORTS fish_history, so an Orca launched from a fish pane hands every
// pane the LAUNCHING worktree's session — even with isolation off (STA-4682).
@@ -317,6 +317,20 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).toHaveBeenCalledOnce()
})
it('cancels an immediate stable-session shutdown before any PTY is spawned', async () => {
spawnMock.mockClear()
const spawn = provider.spawn({
cols: 80,
rows: 24,
sessionId: 'immediate-shutdown-session'
})
await provider.shutdown('immediate-shutdown-session', { immediate: true })
await expect(spawn).rejects.toThrow('PTY spawn canceled: immediate-shutdown-session')
expect(spawnMock).not.toHaveBeenCalled()
})
// Why (#16441): the Codex hook install and trust grant moved into
// buildSpawnEnv, so the env build is now the long await before node-pty
// exists — shutdown must be able to cancel the session id during it.
@@ -341,6 +355,41 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).not.toHaveBeenCalled()
})
it('registers post-build preflight before a nested-microtask shutdown', async () => {
spawnMock.mockClear()
let finishEnvBuild!: () => void
const envProvider = new LocalPtyProvider({
buildSpawnEnv: (_id, baseEnv) =>
new Promise<Record<string, string>>((resolve) => {
finishEnvBuild = () => resolve(baseEnv)
})
})
const spawn = envProvider.spawn({
cols: 80,
rows: 24,
sessionId: 'resolved-env-build-session'
})
const canceledSpawn = expect(spawn).rejects.toThrow(
'PTY spawn canceled: resolved-env-build-session'
)
await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function'))
finishEnvBuild()
const shutdown = new Promise<void>((resolve, reject) => {
queueMicrotask(() => {
queueMicrotask(() => {
envProvider
.shutdown('resolved-env-build-session', { immediate: true })
.then(resolve, reject)
})
})
})
await shutdown
await canceledSpawn
expect(spawnMock).not.toHaveBeenCalled()
})
it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => {
spawnMock.mockClear()
const procA = { ...mockProc, pid: 1001 }
@@ -0,0 +1,157 @@
import type * as pty from 'node-pty'
import type { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import type { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers'
export type PtyShutdownOperation = {
promise: Promise<void>
immediate: boolean
rootSignalled: boolean
proc: pty.IPty
}
export type PendingLocalPtySpawn = {
canceled: boolean
}
export type DataCallback = (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
export type ExitCallback = (payload: {
id: string
code: number
incarnationId?: string
cause?: TerminalExitCause
}) => void
let ptyCounter = 0
export const ptyProcesses = new Map<string, pty.IPty>()
export const ptyIncarnations = new Map<string, string>()
// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown.
export const ptyAgentSessionIds = new Set<string>()
// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY.
export const ptyShutdownOperations = new Map<string, PtyShutdownOperation>()
export const pendingLocalPtySpawns = new Map<string, Set<PendingLocalPtySpawn>>()
export const ptyShellName = new Map<string, string>()
export const ptyAgentForegroundContextPaths = new Map<string, string[]>()
// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit.
// `pid` anchors the identity to the row that proved it (null when ambiguous);
// `at` is the last confirmation, so unanchored job evidence -- only a superset -- cannot hold it forever.
export const ptyLastRecognizedForeground = new Map<
string,
{ name: string; pid: number | null; at: number }
>()
export const ptyTerminalHandle = new Map<string, string>()
export const ptyWorktreeId = new Map<string, string>()
export const ptyInitialCwd = new Map<string, string>()
// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity.
export const ptyWslDistroById = new Map<string, string | null>()
// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination.
export const ptyDisposables = new Map<string, { dispose: () => void }[]>()
export const ptyExitDisposables = new Map<string, { dispose: () => void }>()
export const ptyCleanupCallbacks = new Map<string, () => void>()
export const ptyTerminationMode = new Map<string, 'graceful' | 'force'>()
export const ptyPhysicalExits = new Map<string, PhysicalExitTracker>()
// Why: a wrapper spawn (macOS TCC login) reports its own status, never the
// shell's, so its exit numbers must not be read as the agent's (STA-4536).
export const ptyReportsChildExitStatus = new Map<string, boolean>()
export const ptyForceKillTimers = new Map<string, ReturnType<typeof setTimeout>>()
let loadGeneration = 0
export const ptyLoadGeneration = new Map<string, number>()
export const dataListeners = new Set<DataCallback>()
export const exitListeners = new Set<ExitCallback>()
export const startupIngressByPty = new Map<string, PtyStartupIngress>()
/**
* Disposes native node-pty listeners registered for a PTY id.
*/
export function disposePtyListeners(id: string): void {
const disposables = ptyDisposables.get(id)
if (disposables) {
for (const d of disposables) {
d.dispose()
}
ptyDisposables.delete(id)
}
}
export function disposePtyExitListener(id: string): void {
ptyExitDisposables.get(id)?.dispose()
ptyExitDisposables.delete(id)
}
export function clearLocalPtyForceKillTimer(id: string): void {
const timer = ptyForceKillTimers.get(id)
if (timer) {
clearTimeout(timer)
ptyForceKillTimers.delete(id)
}
}
export function runPtyCleanup(id: string): void {
const cleanup = ptyCleanupCallbacks.get(id)
if (!cleanup) {
return
}
ptyCleanupCallbacks.delete(id)
cleanup()
}
/**
* Removes all local tracking state for a PTY id after teardown.
*/
export function clearPtyState(id: string): void {
clearLocalPtyForceKillTimer(id)
runPtyCleanup(id)
disposePtyListeners(id)
disposePtyExitListener(id)
ptyProcesses.delete(id)
ptyIncarnations.delete(id)
ptyAgentSessionIds.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
ptyLastRecognizedForeground.delete(id)
ptyTerminalHandle.delete(id)
ptyWorktreeId.delete(id)
ptyInitialCwd.delete(id)
ptyWslDistroById.delete(id)
ptyLoadGeneration.delete(id)
ptyTerminationMode.delete(id)
ptyReportsChildExitStatus.delete(id)
ptyPhysicalExits.delete(id)
}
/**
* Allocates either a stable caller-provided PTY id or a new numeric id.
*/
export function allocatePtyId(sessionId: string | undefined): string {
const requested = normalizeLocalCallerSessionId(sessionId)
if (requested) {
return requested
}
let id: string
do {
id = String(++ptyCounter)
} while (ptyProcesses.has(id))
return id
}
export function getLoadGeneration(): number {
return loadGeneration
}
export function advanceLoadGeneration(): number {
return ++loadGeneration
}
export function resetLoadGeneration(): void {
loadGeneration = 0
}
@@ -0,0 +1,37 @@
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import type { PtySpawnOptions } from './types'
export type LocalPtyProviderOptions = {
/** Why: `ctx.command` (pi/omp/claude) must drive overlay source-dir selection — a disk-presence fallback shadows the other agent's extensions. */
buildSpawnEnv?: (
id: string,
baseEnv: Record<string, string>,
ctx?: {
command?: string
launchAgent?: PtySpawnOptions['launchAgent']
codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride']
cwd?: string
shellPath?: string
isWsl?: boolean
wslDistro?: string | null
}
// Why (#16441): Codex launch prep grants hook trust through a codex
// app-server session. `spawn` already awaits, so returning a promise keeps
// the Electron main thread responsive instead of blocking on spawnSync.
) => Record<string, string> | Promise<Record<string, string>>
/** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */
isHistoryEnabled?: () => boolean
/** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */
getWindowsShell?: () => string | undefined
getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined
pwshAvailable?: () => boolean | Promise<boolean>
onSpawned?: (id: string, incarnationId: string) => void
onExit?: (id: string, code: number, incarnationId: string, cause?: TerminalExitCause) => void
onData?: (
id: string,
data: string,
timestamp: number,
sequenceChars?: number,
transformed?: boolean
) => void
}
@@ -436,6 +436,56 @@ describe('LocalPtyProvider', () => {
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
})
it('re-reads Windows shell options after a reentrant configuration change', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const initialPowerShellImplementation = vi.fn(() => 'powershell.exe' as const)
const configuredPwshAvailable = vi.fn(() => true)
provider.configure({
getWindowsShell: () => {
provider.configure({
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable: configuredPwshAvailable
})
return 'powershell.exe'
},
getWindowsPowerShellImplementation: initialPowerShellImplementation
})
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
expect(initialPowerShellImplementation).not.toHaveBeenCalled()
expect(configuredPwshAvailable).toHaveBeenCalledOnce()
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
})
it('registers a probe-complete spawn before a later microtask shutdown', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
getWindowsShell: () => 'powershell.exe',
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable: () => false
})
const callsBeforeSpawn = spawnMock.mock.calls.length
const spawn = provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo',
sessionId: 'probe-shutdown-session'
})
const canceledSpawn = expect(spawn).rejects.toThrow(
'PTY spawn canceled: probe-shutdown-session'
)
const shutdown = new Promise<void>((resolve, reject) => {
queueMicrotask(() => {
provider.shutdown('probe-shutdown-session', { immediate: true }).then(resolve, reject)
})
})
await shutdown
await canceledSpawn
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn)
})
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const savedCodexHome = process.env.CODEX_HOME
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
import type * as pty from 'node-pty'
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { resolveProcessExitCause } from '../../shared/terminal-exit-cause'
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
import { getSpawnedShellName } from './local-pty-launch-helpers'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import {
clearPtyState,
dataListeners,
exitListeners,
getLoadGeneration,
ptyAgentForegroundContextPaths,
ptyAgentSessionIds,
ptyDisposables,
ptyExitDisposables,
ptyIncarnations,
ptyInitialCwd,
ptyLoadGeneration,
ptyPhysicalExits,
ptyProcesses,
ptyReportsChildExitStatus,
ptyShellName,
ptyTerminalHandle,
ptyTerminationMode,
ptyWorktreeId,
ptyWslDistroById,
startupIngressByPty
} from './local-pty-provider-state'
import { createLocalPtyShellReadinessSession } from './local-pty-shell-readiness-session'
import { destroyPtyProcess, createPtyPhysicalExit } from './local-pty-termination'
import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready-startup-command'
import type { PtySpawnOptions, PtySpawnResult } from './types'
export function activateLocalPtySession(args: {
id: string
incarnationId: string
spawn: PtySpawnOptions
getOptions: () => LocalPtyProviderOptions
plan: LocalPtyLaunchPlan
env: Record<string, string>
proc: pty.IPty
reportsChildExitStatus: boolean
spawnedWslDistro: string | null | undefined
}): PtySpawnResult {
const { id, incarnationId, spawn, getOptions, plan, env, proc, spawnedWslDistro } = args
createPtyPhysicalExit(id)
ptyReportsChildExitStatus.set(id, args.reportsChildExitStatus)
ptyProcesses.set(id, proc)
ptyInitialCwd.set(id, plan.cwd)
if (spawnedWslDistro !== undefined) {
ptyWslDistroById.set(id, spawnedWslDistro)
}
// Why both: launchAgent is explicit intent that survives command rewrites; recognition catches bare agent command lines.
if (spawn.launchAgent || plan.startupAgentRecognition) {
ptyAgentSessionIds.add(id)
}
ptyShellName.set(id, getSpawnedShellName(plan.shellPath))
if (env.ORCA_TERMINAL_HANDLE) {
ptyTerminalHandle.set(id, env.ORCA_TERMINAL_HANDLE)
}
if (spawn.worktreeId) {
ptyWorktreeId.set(id, spawn.worktreeId)
}
ptyAgentForegroundContextPaths.set(
id,
getAgentForegroundContextPaths({ cwd: spawn.cwd, worktreeId: spawn.worktreeId })
)
ptyLoadGeneration.set(id, getLoadGeneration())
ptyIncarnations.set(id, incarnationId)
getOptions().onSpawned?.(id, incarnationId)
const emitIngressData = (emission: PtyIngressEmission): void => {
const sequenceChars = emission.rawEndSeq - emission.rawStartSeq
if (emission.transformed || sequenceChars !== emission.data.length) {
getOptions().onData?.(id, emission.data, Date.now(), sequenceChars, true)
} else {
getOptions().onData?.(id, emission.data, Date.now())
}
for (const cb of dataListeners) {
cb(
emission.transformed || sequenceChars !== emission.data.length
? {
id,
data: emission.data,
sequenceChars,
seq: emission.rawEndSeq,
transformed: true
}
: { id, data: emission.data }
)
}
}
const startupIngress = new PtyStartupIngress({
...(spawn.startupIngress ? { intent: spawn.startupIngress } : {}),
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: plan.shellPath,
wslDistro: spawnedWslDistro
}),
write: (data) => proc.write(data),
onEmission: emitIngressData
})
startupIngressByPty.set(id, startupIngress)
// Shell-ready startup command support
const readiness = createLocalPtyShellReadinessSession({
id,
spawn,
plan,
env,
proc,
startupIngress
})
const disposables: { dispose: () => void }[] = []
const onDataDisposable = proc.onData((rawData) => {
readiness.acceptData(rawData)
})
if (onDataDisposable) {
disposables.push(onDataDisposable)
}
const onExitDisposable = proc.onExit(({ exitCode, signal }) => {
// Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the
// cause is built here, where the signal and the spawn's trustworthiness
// are both still in hand.
const cause = resolveProcessExitCause({
exitCode,
signal,
hostReportsChildExitStatus: ptyReportsChildExitStatus.get(id)
})
const wasTerminationRequested = ptyTerminationMode.has(id)
ptyPhysicalExits.get(id)?.markExited()
// Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid.
if (process.platform !== 'win32') {
;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {}
}
readiness.prepareForExit()
clearPtyState(id)
startupIngress.drainAndClose()
startupIngressByPty.delete(id)
// Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md.
destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested })
ptyReportsChildExitStatus.delete(id)
getOptions().onExit?.(id, exitCode, incarnationId, cause)
for (const cb of exitListeners) {
cb({ id, code: exitCode, incarnationId, cause })
}
})
if (onExitDisposable) {
ptyExitDisposables.set(id, onExitDisposable)
}
ptyDisposables.set(id, disposables)
if (spawn.command && !plan.startupCommandDeliveredInShellArgs) {
// Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit.
const spawnedShellName = getSpawnedShellName(plan.shellPath).toLowerCase()
const bracketedPasteSafe =
process.platform !== 'win32' &&
isBracketedPasteSafeShell({
shellName: spawnedShellName,
waitsForShellReady: plan.shellReadyLaunch?.supportsReadyMarker === true
})
writeStartupCommandWhenShellReady(
readiness.shellReadyPromise,
proc,
spawn.command,
(cleanup) => {
readiness.setStartupCommandCleanup(cleanup)
},
{ bracketedPasteSafe }
)
}
// Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child.
const rawPid = proc.pid
const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null
return {
id,
incarnationId,
pid,
...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {})
}
}
@@ -0,0 +1,170 @@
import { existsSync } from 'node:fs'
import { basename } from 'node:path'
import type * as pty from 'node-pty'
import { readPtsName } from '../pty/node-pty-pts-name'
import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group'
import { isWslAvailableAsync } from '../wsl'
import { resolveGitBashPath } from '../git-bash'
import { resolveProcessCwd } from './process-cwd'
import {
dataListeners,
exitListeners,
ptyIncarnations,
ptyInitialCwd,
ptyProcesses,
ptyShellName,
ptyTerminalHandle,
ptyWorktreeId,
ptyWslDistroById,
startupIngressByPty,
type DataCallback,
type ExitCallback
} from './local-pty-provider-state'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import type { PtyProcessInfo } from './types'
export function writeLocalPty(id: string, data: string): boolean {
// Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of
// those is still held, which they must not overtake (#13137, #7329, #15559).
if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) {
return true
}
const proc = ptyProcesses.get(id)
if (!proc) {
return false
}
proc.write(data)
return true
}
export function resizeLocalPty(id: string, cols: number, rows: number): void {
ptyProcesses.get(id)?.resize(cols, rows)
}
// Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure.
export function pauseLocalPtyProducer(id: string): void {
try {
ptyProcesses.get(id)?.pause()
} catch {
/* PTY already destroyed */
}
}
export function resumeLocalPtyProducer(id: string): void {
try {
ptyProcesses.get(id)?.resume()
} catch {
/* PTY already destroyed */
}
}
// Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check.
export async function getLocalPtyAppliedSize(
id: string
): Promise<{ cols: number; rows: number } | null> {
const proc = ptyProcesses.get(id)
if (!proc || proc.cols <= 0 || proc.rows <= 0) {
return null
}
return { cols: proc.cols, rows: proc.rows }
}
export async function sendLocalPtySignal(id: string, signal: string): Promise<void> {
const proc = ptyProcesses.get(id)
if (!proc) {
return
}
const signalRootPid = (): void => {
try {
process.kill(proc.pid, signal)
} catch {
/* Process may already be dead */
}
}
// Why only SIGWINCH: see posix-pty-foreground-group — a real resize reaches the
// tty's foreground group, which proc.pid is never a member of.
if (signal === 'SIGWINCH') {
signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), signal, signalRootPid)
return
}
signalRootPid()
}
export async function getLocalPtyCwd(id: string): Promise<string> {
const proc = ptyProcesses.get(id)
// Why: '' not throw on unknown id — renderer reads empty as "try next fallback"; throwing is noisy for a normal case.
if (!proc) {
return ''
}
// Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it.
return resolveProcessCwd(proc.pid)
}
export async function clearLocalPtyBuffer(id: string): Promise<void> {
// Why: ConPTY keeps its own screen buffer, so xterm clear() alone leaves a stale-cursor gap on the next prompt; POSIX no-op.
// No PSReadLine form-feed nudge here (unlike the daemon): safe only at an empty prompt, which this provider can't detect.
try {
startupIngressByPty.get(id)?.snapshotBarrier()
ptyProcesses.get(id)?.clear()
} catch {
/* PTY may have just exited */
}
}
export function closeLocalPtyStartupQueryAuthority(id: string): number {
return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0
}
export async function listLocalPtyProcesses(): Promise<PtyProcessInfo[]> {
return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({
id,
...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}),
cwd: ptyInitialCwd.get(id) ?? '',
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}),
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {})
}))
}
export async function getDefaultLocalPtyShell(
getOptions: () => LocalPtyProviderOptions
): Promise<string> {
if (process.platform === 'win32') {
return getOptions().getWindowsShell?.() || process.env.COMSPEC || 'powershell.exe'
}
return process.env.SHELL || '/bin/zsh'
}
export async function getLocalPtyProfiles(): Promise<{ name: string; path: string }[]> {
if (process.platform === 'win32') {
const profiles: { name: string; path: string }[] = [
{ name: 'PowerShell', path: 'powershell.exe' },
{ name: 'Command Prompt', path: 'cmd.exe' }
]
const gitBashPath = resolveGitBashPath()
if (gitBashPath) {
profiles.push({ name: 'Git Bash', path: gitBashPath })
}
if (await isWslAvailableAsync()) {
profiles.push({ name: 'WSL', path: 'wsl.exe' })
}
return profiles
}
const shells = ['/bin/zsh', '/bin/bash', '/bin/sh']
return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s }))
}
export function onLocalPtyData(callback: DataCallback): () => void {
dataListeners.add(callback)
return () => dataListeners.delete(callback)
}
export function onLocalPtyExit(callback: ExitCallback): () => void {
exitListeners.add(callback)
return () => exitListeners.delete(callback)
}
export function getLocalPtyProcess(id: string): pty.IPty | undefined {
return ptyProcesses.get(id)
}
@@ -0,0 +1,148 @@
import type * as pty from 'node-pty'
import type { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import {
createShellStartupOutputScanState,
drainShellStartupOutputScanState,
scanShellStartupOutput
} from '../shell-startup-output-scanner'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import { ptyCleanupCallbacks } from './local-pty-provider-state'
import {
STARTUP_COMMAND_READY_MAX_WAIT_MS,
type ShellReadySignal
} from './local-pty-shell-ready-startup-command'
import type { PtySpawnOptions } from './types'
export type LocalPtyShellReadinessSession = {
shellReadyPromise: Promise<ShellReadySignal>
acceptData(rawData: string): void
setStartupCommandCleanup(cleanup: () => void): void
prepareForExit(): void
}
export function createLocalPtyShellReadinessSession(args: {
id: string
spawn: PtySpawnOptions
plan: LocalPtyLaunchPlan
env: Record<string, string>
proc: pty.IPty
startupIngress: PtyStartupIngress
}): LocalPtyShellReadinessSession {
const { id, spawn, plan, env, proc, startupIngress } = args
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
let shellReadyTimeout: ReturnType<typeof setTimeout> | null = null
let shellStartupPid: number | null = null
let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
let shellStartupOutputScanState = plan.shellReadyLaunch?.supportsReadyMarker
? createShellStartupOutputScanState()
: null
const shellReadyPromise = spawn.command
? new Promise<ShellReadySignal>((resolve) => {
resolveShellReady = resolve
})
: Promise.resolve({ postMarkerBytesObserved: false })
const finishShellReady = (signal: ShellReadySignal): void => {
if (!resolveShellReady) {
return
}
if (shellReadyTimeout) {
clearTimeout(shellReadyTimeout)
shellReadyTimeout = null
}
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
const resolve = resolveShellReady
resolveShellReady = null
resolve(signal)
}
const releaseHeldShellReadyBytes = (): void => {
if (!shellStartupOutputScanState) {
return
}
const heldBytes = drainShellStartupOutputScanState(shellStartupOutputScanState)
shellStartupOutputScanState = null
if (heldBytes.length === 0) {
return
}
startupIngress.accept(heldBytes)
}
if (shellStartupOutputScanState) {
shellPromptReadinessProbe = createShellPromptReadinessProbe({
slavePath: readPtySlavePath(proc),
shellPath: plan.shellPath,
shellCwd: plan.effectiveCwd,
shellPathEnv: env.PATH,
getShellPid: () => shellStartupPid,
onPromptReady: () => {
console.warn(
`[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
)
releaseHeldShellReadyBytes()
finishShellReady({ postMarkerBytesObserved: true })
}
})
}
if (spawn.command) {
if (plan.shellReadyLaunch?.supportsReadyMarker) {
shellReadyTimeout = setTimeout(() => {
releaseHeldShellReadyBytes()
finishShellReady({ postMarkerBytesObserved: false })
}, STARTUP_COMMAND_READY_MAX_WAIT_MS)
} else {
finishShellReady({ postMarkerBytesObserved: false })
}
}
let startupCommandCleanup: (() => void) | null = null
if (spawn.command) {
ptyCleanupCallbacks.set(id, () => {
if (shellReadyTimeout) {
clearTimeout(shellReadyTimeout)
shellReadyTimeout = null
}
releaseHeldShellReadyBytes()
startupCommandCleanup?.()
startupCommandCleanup = null
resolveShellReady = null
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
})
}
return {
shellReadyPromise,
acceptData: (rawData) => {
let data = rawData
if (shellStartupOutputScanState && resolveShellReady) {
const scanned = scanShellStartupOutput(shellStartupOutputScanState, data)
data = scanned.output
if (scanned.shellPid) {
shellStartupPid = scanned.shellPid
}
if (scanned.ready) {
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
}
}
startupIngress.accept(data)
if (resolveShellReady && data.length > 0) {
shellPromptReadinessProbe?.notifyOutput(data)
}
},
setStartupCommandCleanup: (cleanup) => {
startupCommandCleanup = cleanup
},
prepareForExit: () => {
if (shellReadyTimeout) {
clearTimeout(shellReadyTimeout)
shellReadyTimeout = null
}
startupCommandCleanup?.()
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
}
}
}
@@ -0,0 +1,81 @@
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { isWindowsGitBashShellPath } from '../git-bash'
import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import { awaitCancelableLocalPtySpawn } from './local-pty-spawn-state'
import type { PtySpawnOptions } from './types'
export function buildLocalPtySpawnEnvironment(args: {
id: string
spawn: PtySpawnOptions
getOptions: () => LocalPtyProviderOptions
plan: LocalPtyLaunchPlan
}): Record<string, string> | Promise<Record<string, string>> {
const { id, spawn, getOptions, plan } = args
const spawnEnv: Record<string, string> = {
...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working.
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
// Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them.
FORCE_HYPERLINK: '1'
} as Record<string, string>
// Why: Orca can be launched from an Orca terminal; pane identity belongs to the child PTY, not the parent shell.
removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env)
removeAppImageRuntimeEnv(spawnEnv)
removeInheritedNoColor(spawnEnv)
for (const key of spawn.envToDelete ?? []) {
delete spawnEnv[key]
}
if (spawn.env?.TERM) {
spawnEnv.TERM = spawn.env.TERM
}
spawnEnv.LANG ??= 'en_US.UTF-8'
// Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK.
if (process.platform === 'win32') {
spawnEnv.PYTHONUTF8 ??= '1'
if (isWindowsGitBashShellPath(plan.shellPath)) {
// Why: Git for Windows login files otherwise cd to $HOME, ignoring node-pty's cwd for repo-scoped terminals.
spawnEnv.CHERE_INVOKING ??= '1'
}
}
if (!getOptions().buildSpawnEnv) {
return spawnEnv
}
// Why (#16441): building the env now awaits Codex hook installs and trust
// grants, so shutdown must be able to cancel this session id here too.
return awaitCancelableLocalPtySpawn(
id,
getOptions().buildSpawnEnv!(id, spawnEnv, {
command: spawn.command,
launchAgent: spawn.launchAgent,
codexHomePathOverride: spawn.codexHomePathOverride,
cwd: plan.cwd,
shellPath: plan.shellPath,
isWsl: plan.isWslShell,
wslDistro: plan.launchWslDistro
})
)
}
export function enforceLocalPtySpawnEnvironmentOverrides(
spawn: PtySpawnOptions,
finalEnv: Record<string, string>
): void {
// Why: app-level env hooks can re-add scrubbed vars; delete last so shims like Claude Agent Teams keep their PATH.
for (const key of spawn.envToDelete ?? []) {
delete finalEnv[key]
}
if (spawn.env?.TERM) {
finalEnv.TERM = spawn.env.TERM
}
}
@@ -0,0 +1,65 @@
import type { PtySpawnResult } from './types'
import {
pendingLocalPtySpawns,
ptyProcesses,
ptyWslDistroById,
type PendingLocalPtySpawn
} from './local-pty-provider-state'
/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty
* process exists yet, so cancellation can only be observed after the await. */
export async function awaitCancelableLocalPtySpawn<T>(
id: string,
operation: T | Promise<T>
): Promise<T> {
const pendingSpawn: PendingLocalPtySpawn = { canceled: false }
const pending = pendingLocalPtySpawns.get(id) ?? new Set()
pending.add(pendingSpawn)
pendingLocalPtySpawns.set(id, pending)
try {
const result = await operation
if (pendingSpawn.canceled) {
throw new Error(`PTY spawn canceled: ${id}`)
}
return result
} finally {
pending.delete(pendingSpawn)
if (pending.size === 0) {
pendingLocalPtySpawns.delete(id)
}
}
}
export function cancelPendingLocalPtySpawns(id: string): void {
const pending = pendingLocalPtySpawns.get(id)
if (!pending) {
return
}
for (const pendingSpawn of pending) {
pendingSpawn.canceled = true
}
}
export function cancelAllPendingLocalPtySpawns(): void {
for (const id of pendingLocalPtySpawns.keys()) {
cancelPendingLocalPtySpawns(id)
}
}
export function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null {
const existing = ptyProcesses.get(id)
if (!existing) {
return null
}
try {
existing.resize(cols, rows)
} catch {
/* Existing PTY may reject resize during teardown; still return the live handle. */
}
return {
id,
pid: existing.pid,
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}),
isReattach: true
}
}
+122
View File
@@ -0,0 +1,122 @@
import { randomUUID } from 'node:crypto'
import { win32 as pathWin32 } from 'node:path'
import * as pty from 'node-pty'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { prepareMacosTccLoginShell } from './macos-tcc-login-shell'
import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment'
import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers'
import { createLocalPtyLaunchPlan, DeferredLocalPtyLaunchPlan } from './local-pty-launch-plan'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import { allocatePtyId, ptyShutdownOperations } from './local-pty-provider-state'
import { activateLocalPtySession } from './local-pty-session-activation'
import {
buildLocalPtySpawnEnvironment,
enforceLocalPtySpawnEnvironmentOverrides
} from './local-pty-spawn-environment'
import { awaitCancelableLocalPtySpawn, reattachLocalPty } from './local-pty-spawn-state'
import { spawnShellWithFallback } from './local-pty-utils'
import { updateHistoryEnvForFallback, type HistoryInjectionResult } from '../terminal-history'
import type { PtySpawnOptions, PtySpawnResult } from './types'
export async function spawnLocalPty(
args: PtySpawnOptions,
getOptions: () => LocalPtyProviderOptions
): Promise<PtySpawnResult> {
const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true)
if (reattachId) {
const pendingShutdown = ptyShutdownOperations.get(reattachId)
if (pendingShutdown) {
await pendingShutdown.promise
}
const existing = reattachLocalPty(reattachId, args.cols, args.rows)
if (existing) {
return existing
}
}
if (args.attachOnly) {
throw new SessionNotFoundError(args.sessionId ?? '')
}
const id = allocatePtyId(reattachId ?? undefined)
const incarnationId = randomUUID()
const planResult = createLocalPtyLaunchPlan(args, getOptions)
const plan =
planResult instanceof DeferredLocalPtyLaunchPlan
? planResult.finish(await planResult.availability)
: planResult
const envResult = buildLocalPtySpawnEnvironment({
id,
spawn: args,
getOptions,
plan
})
const finalEnv = envResult instanceof Promise ? await envResult : envResult
enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv)
const historyResult = finalizeLocalPtySpawnEnvironment({
spawn: args,
getOptions,
plan,
env: finalEnv
})
// Why: the async macOS capability probe runs before node-pty exists.
await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell())
if (args.signal?.aborted) {
throw new Error('client_disconnected')
}
// Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell.
const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null
if (concurrentWinner) {
return concurrentWinner
}
const spawnResult = spawnShellWithFallback({
shellPath: plan.shellPath,
shellArgs: plan.shellArgs,
cols: args.cols,
rows: args.rows,
cwd: plan.effectiveCwd,
env: finalEnv,
termName: finalEnv.TERM,
ptySpawn: pty.spawn,
getShellReadyConfig: plan.getFallbackShellReadyConfig,
launchEnvKeys: plan.primaryLaunchEnvKeys,
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
onBeforeFallbackSpawn: historyResult?.historyDir
? (env, fallbackShell) =>
updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult)
: undefined,
windowsFallbackAttempts: plan.windowsFallbackAttempts
})
args.onPtySpawnCommitted?.()
plan.shellPath = spawnResult.shellPath
// Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write.
if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) {
plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs
}
if (args.command && plan.getFallbackShellReadyConfig) {
plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath)
}
if (process.platform !== 'win32') {
finalEnv.SHELL = plan.shellPath
}
const proc = spawnResult.process
const spawnedShellIsWsl =
process.platform === 'win32' && pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe'
const spawnedWslDistro = spawnedShellIsWsl
? (plan.launchWslDistro ?? undefined)
: process.platform === 'win32'
? null
: undefined
return activateLocalPtySession({
id,
incarnationId,
spawn: args,
getOptions,
plan,
env: finalEnv,
proc,
reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false,
spawnedWslDistro
})
}
+268
View File
@@ -0,0 +1,268 @@
import type * as pty from 'node-pty'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups'
import { terminatePtyJob } from '../windows/windows-pty-job'
import {
clearLocalPtyForceKillTimer,
clearPtyState,
disposePtyExitListener,
disposePtyListeners,
ptyAgentSessionIds,
ptyForceKillTimers,
ptyPhysicalExits,
ptyProcesses,
ptyShutdownOperations,
ptyTerminationMode,
ptyLoadGeneration,
runPtyCleanup,
type PtyShutdownOperation
} from './local-pty-provider-state'
import {
cancelAllPendingLocalPtySpawns,
cancelPendingLocalPtySpawns
} from './local-pty-spawn-state'
export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000
export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000
export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250
export function createPtyPhysicalExit(id: string): void {
ptyPhysicalExits.set(id, new PhysicalExitTracker())
}
function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise<void> {
if (!physicalExit) {
return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`))
}
return physicalExit.waitForExit(
LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS,
() => new Error(`Timed out waiting for PTY process exit: ${id}`)
)
}
function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void {
if (process.platform === 'win32') {
proc.kill()
return
}
if (!immediate) {
proc.kill('SIGTERM')
return
}
forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL'))
}
function armLocalPtyForceKill(
id: string,
proc: pty.IPty,
options: { delayMs?: number; attemptsRemaining?: number } = {}
): void {
if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') {
return
}
const attemptsRemaining = options.attemptsRemaining ?? 2
const timer = setTimeout(() => {
ptyForceKillTimers.delete(id)
if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') {
return
}
ptyTerminationMode.set(id, 'force')
try {
killLocalPtyProcess(proc, true)
} catch (error) {
ptyTerminationMode.set(id, 'graceful')
console.error('[pty] failed to force-kill PTY after graceful deadline', { id, error })
// Why: a transient native rejection must not consume the only SIGKILL owner while shutdown still awaits physical exit.
if (attemptsRemaining > 1) {
armLocalPtyForceKill(id, proc, {
delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS,
attemptsRemaining: attemptsRemaining - 1
})
}
}
}, options.delayMs ?? LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
timer.unref?.()
ptyForceKillTimers.set(id, timer)
}
/**
* Disposes the native PTY handle while avoiding recycled-pid signals on POSIX.
*/
export function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void {
// Why: neutralize proc.kill before destroy(), whose close-listener SIGHUPs a possibly-recycled POSIX pid; destroy() frees the ptmx fd (docs/fix-pty-fd-leak.md); on Windows destroy() is itself kill().
if (process.platform === 'win32' && options.alreadyKilled) {
return
}
if (process.platform !== 'win32') {
;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {}
}
try {
;(proc as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow — already torn down */
}
}
/**
* Requests local PTY termination while retaining physical-exit ownership.
*/
function requestPtyTermination(id: string, proc: pty.IPty): void {
runPtyCleanup(id)
disposePtyListeners(id)
const previousMode = ptyTerminationMode.get(id)
// Why: cleanup neutralizes proc.kill below, so escalate an outstanding graceful request before its deadline is disabled.
if (previousMode !== 'force') {
clearLocalPtyForceKillTimer(id)
ptyTerminationMode.set(id, 'force')
try {
killLocalPtyProcess(proc, true)
} catch {
if (previousMode === 'graceful') {
ptyTerminationMode.set(id, previousMode)
armLocalPtyForceKill(id, proc, {
delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS,
attemptsRemaining: 1
})
} else {
ptyTerminationMode.delete(id)
}
/* Process may already be dead. */
return
}
}
// Why: shutdown and orphan cleanup can race; keep onExit + tracker installed until the OS proves the child was reaped.
destroyPtyProcess(proc, { alreadyKilled: true })
}
function requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void {
const previousMode = ptyTerminationMode.get(id)
// Why: ConPTY has no graceful signal — its first bare kill closes the pseudoconsole, so treat it as a final force request.
const requestedMode = immediate || process.platform === 'win32' ? 'force' : 'graceful'
if (!previousMode || (requestedMode === 'force' && previousMode !== 'force')) {
ptyTerminationMode.set(id, requestedMode)
try {
killLocalPtyProcess(proc, immediate)
if (requestedMode === 'graceful') {
armLocalPtyForceKill(id, proc)
} else {
clearLocalPtyForceKillTimer(id)
}
} catch (error) {
if (previousMode) {
ptyTerminationMode.set(id, previousMode)
} else {
ptyTerminationMode.delete(id)
}
throw error
}
}
}
async function shutdownTrackedPty(
id: string,
proc: pty.IPty,
operation: PtyShutdownOperation
): Promise<void> {
const physicalExit = ptyPhysicalExits.get(id)
const signalRoot = (): void => {
// Why: natural exit can race the sweep — never signal after this PTY loses ownership.
if (ptyProcesses.get(id) !== proc) {
return
}
// Cancel startup delivery now, but keep the exit listener and ownership maps until node-pty reports physical exit.
runPtyCleanup(id)
operation.rootSignalled = true
requestTrackedPtyShutdown(id, proc, operation.immediate)
}
if (ptyAgentSessionIds.has(id)) {
// Why: POSIX needs a pre-kill descendant snapshot; Windows tree-kills only when the
// identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd
// (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone.
await killWithDescendantSweep(proc.pid, signalRoot, {
ownsRoot: () => ptyProcesses.get(id) === proc,
terminateOwnedTree: () => terminatePtyJob(proc)
})
} else if (process.platform === 'win32' && operation.immediate) {
// Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll
// skips the console reap), so a live `pnpm i`/`node` keeps the ConPTY console alive and
// holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`;
// otherwise root close alone, and detached children may block physical stop (#10004).
await killWithDescendantSweep(proc.pid, signalRoot, {
ownsRoot: () => ptyProcesses.get(id) === proc,
terminateOwnedTree: () => terminatePtyJob(proc)
})
} else {
signalRoot()
}
await waitForPtyPhysicalExit(id, physicalExit)
}
export async function shutdownLocalPty(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean }
): Promise<void> {
cancelPendingLocalPtySpawns(id)
const pending = ptyShutdownOperations.get(id)
if (pending) {
if (opts.immediate === true) {
pending.immediate = true
if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) {
requestTrackedPtyShutdown(id, pending.proc, true)
}
}
await pending.promise
return
}
const proc = ptyProcesses.get(id)
if (!proc) {
return
}
const entry: PtyShutdownOperation = {
promise: Promise.resolve(),
immediate: opts.immediate === true,
rootSignalled: false,
proc
}
entry.promise = shutdownTrackedPty(id, proc, entry)
ptyShutdownOperations.set(id, entry)
try {
await entry.promise
} finally {
if (ptyShutdownOperations.get(id) === entry) {
ptyShutdownOperations.delete(id)
}
}
}
export function killOrphanedLocalPtys(currentGeneration: number): { id: string }[] {
const killed: { id: string }[] = []
for (const [id, proc] of ptyProcesses) {
if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) {
requestPtyTermination(id, proc)
killed.push({ id })
}
}
return killed
}
export function killAllLocalPtys(): void {
cancelAllPendingLocalPtySpawns()
for (const [id, proc] of ptyProcesses) {
runPtyCleanup(id)
disposePtyListeners(id)
disposePtyExitListener(id)
if (!(process.platform === 'win32' && ptyTerminationMode.has(id))) {
try {
proc.kill()
} catch {
/* Process may already be dead. */
}
}
// Why: app quit can't retain NAPI callbacks into FreeEnvironment; process exit is the final handle boundary here.
destroyPtyProcess(proc, { alreadyKilled: true })
// Why: app quit replaces node-pty's onExit as final owner; overlapping shutdown waiters must join this boundary.
ptyPhysicalExits.get(id)?.markExited()
clearPtyState(id)
}
}
@@ -0,0 +1,91 @@
import { win32 as pathWin32 } from 'node:path'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { addWslEnvKeys } from '../wsl-env'
import { parseWslPath } from '../wsl'
import { isWindowsGitBashShellPath } from '../git-bash'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import {
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
resolveWindowsShellLaunchArgs
} from './windows-shell-args'
import type { PtySpawnOptions } from './types'
export function finalizeWindowsLocalPtySpawnEnvironment(args: {
spawn: PtySpawnOptions
plan: LocalPtyLaunchPlan
env: Record<string, string>
}): void {
const { spawn, plan, env } = args
const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null
if (pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe') {
if (codexHomeWslInfo) {
if (plan.launchWslDistro && plan.launchWslDistro !== codexHomeWslInfo.distro) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else {
env.CODEX_HOME = codexHomeWslInfo.linuxPath
env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath
// Why: wsl.exe only imports non-default env vars named in WSLENV.
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
if (!plan.launchWslDistro) {
const resolved = resolveWindowsShellLaunchArgs(
plan.shellPath,
plan.cwd,
plan.defaultCwd,
{
distro: codexHomeWslInfo.distro
}
)
plan.shellArgs = resolved.shellArgs
plan.effectiveCwd = resolved.effectiveCwd
plan.validationCwd = resolved.validationCwd
plan.startupCommandDeliveredInShellArgs =
resolved.startupCommandDeliveredInShellArgs === true
}
}
} else if (isHostCodexHomeForWsl(env.CODEX_HOME)) {
// Why: Orca's Codex home is host-local; WSL Codex must use its Linux-side ~/.codex, not a Windows path.
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else if (env.CODEX_HOME) {
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
}
if (env.CLAUDE_CONFIG_DIR) {
// Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import.
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
// Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL.
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
// Why: WSL Codex homes are Linux paths Windows can't use; also drop ORCA_CODEX_HOME (shell-ready restores CODEX_HOME from it).
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
}
const shellBasename = pathWin32.basename(plan.shellPath).toLowerCase()
const codexLaunchPreflightCommand = env.ORCA_CODEX_LAUNCH_PREFLIGHT
if (
codexLaunchPreflightCommand &&
(shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(plan.shellPath))
) {
if (shellBasename === 'cmd.exe') {
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
}
const resolved = resolveWindowsShellLaunchArgs(
plan.shellPath,
plan.cwd,
plan.defaultCwd,
plan.launchWslContext,
spawn.command,
codexLaunchPreflightCommand
)
plan.shellArgs = resolved.shellArgs
plan.effectiveCwd = resolved.effectiveCwd
plan.validationCwd = resolved.validationCwd
plan.startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
}
@@ -26,7 +26,7 @@ main/git/command-runner/wsl-command-resolution.ts
main/git/wsl-git-read-environment.ts
main/ipc/filesystem-watcher-wsl.ts
main/local-worktree-filesystem.ts
main/providers/local-pty-provider.ts
main/providers/local-pty-spawn.ts
main/rate-limits/claude-pty.ts
main/rate-limits/codex-fetcher.ts
main/wsl-availability.ts
+21 -1
View File
@@ -141,6 +141,22 @@ function bindsWslBinaryToASpawnedIdentifier(source: string): boolean {
return false
}
function passesComparedWslShellPathToSpawnSpec(source: string): boolean {
for (const match of source.matchAll(
/\bbasename\(\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\)\.toLowerCase\(\)\s*===\s*['"`]wsl\.exe['"`]/g
)) {
const shellPath = match[1]!.replace(/[.$]/g, '\\$&')
const spawnCall = new RegExp(
`\\b\\w*(?:spawn|exec|run)\\w*\\s*\\(\\s*(?:${shellPath}\\b|\\{[\\s\\S]{0,2000}?\\bshellPath\\s*:\\s*${shellPath}\\b)`,
'i'
)
if (spawnCall.test(source)) {
return true
}
}
return false
}
function findSpawnSites(): string[] {
const offenders = new Set<string>()
for (const path of collectSourceFiles(SOURCE_ROOT)) {
@@ -157,7 +173,10 @@ function findSpawnSites(): string[] {
offenders.add(relativePath)
}
}
if (bindsWslBinaryToASpawnedIdentifier(source)) {
if (
bindsWslBinaryToASpawnedIdentifier(source) ||
passesComparedWslShellPathToSpawnSpec(source)
) {
offenders.add(relativePath)
}
}
@@ -346,6 +365,7 @@ describe('wsl.exe is spawned through one runner', () => {
// so it passed even for a scanner that found nothing. This fails the moment
// detection stops seeing a call that is definitely there.
expect(offenders).toContain('main/git/command-runner/wsl-command-resolution.ts')
expect(offenders).toContain('main/providers/local-pty-spawn.ts')
})
it('adds no new direct wsl.exe spawn', () => {
@@ -272,7 +272,7 @@ const DIRECT_SINGLE_SOURCE_SURFACES: readonly {
marker: 'isCursorAgentTitle'
},
{
path: 'src/main/providers/local-pty-provider.ts',
path: 'src/main/providers/local-pty-session-activation.ts',
classification: 'action-consumer',
marker: 'launchAgent'
},