refactor(comments): slim verbose comments in main core (runtime/ipc/daemon/pty) (#9542)

Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.

Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.

Area: main — core runtime, ipc, daemon, pty, providers. 73 files changed, 3206 insertions(+), 10475 deletions(-).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 03:18:24 -07:00
committed by GitHub
co-authored by Orca
parent 53aeeb710c
commit 98b00d3a64
73 changed files with 3205 additions and 10474 deletions
@@ -1,8 +1,4 @@
// Why: when first-message auto-name is on, the first time an agent starts
// working in a freshly-created workspace we replace the auto-generated creature
// branch (e.g. `you/Nautilus`) with a short, work-derived name. This module
// owns the orchestration: gate on the signal, enforce the safety guardrails,
// summarize the prompt via the configured agent, and rename.
// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
@@ -59,10 +55,7 @@ export type FirstWorkBranchRenameDeps = {
setDisplayName: (worktreeId: string, displayName: string) => void
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise<boolean>
/** Record (or clear with null) a user-facing auto-rename generation failure
* so the sidebar can show a "rename failed" badge instead of silent retries.
* `failureOutput` carries the bounded full CLI output for local on-demand
* display; omitted/null replaces any stale capture. */
/** Record (or clear with null) an auto-rename failure for the sidebar "rename failed" badge; `failureOutput` carries bounded CLI output for on-demand display. */
setRenameError: (
worktreeId: string,
error: string | null,
@@ -74,12 +67,7 @@ export type FirstWorkBranchRenameDeps = {
onRenamed: (repoId: string) => void
}
// Why: `inFlight` guards against concurrent runs while generation (seconds) is
// pending; `settled` short-circuits worktrees we've reached a definitive verdict
// on (renamed, or permanently ineligible) so we never re-probe git on later
// `working` events — important because the hook fires every turn for every pane.
// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT
// settled, so the real first prompt can still succeed on a later event.
// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later).
const inFlightWorktreeIds = new Set<string>()
const settledWorktreeIds = new Set<string>()
export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500
@@ -91,8 +79,7 @@ export function resetFirstWorkBranchRenameState(): void {
}
function rememberSettledWorktreeId(worktreeId: string): void {
// Why: the app can see unbounded worktree ids over a long session; evicting
// oldest entries trades a rare re-probe for bounded process memory.
// Why: worktree ids are unbounded over a long session; evict oldest to bound memory (costs a rare re-probe).
settledWorktreeIds.delete(worktreeId)
settledWorktreeIds.add(worktreeId)
while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) {
@@ -115,8 +102,7 @@ export async function maybeAutoRenameBranchOnFirstWork(
if (!deps.getSettings().autoRenameBranchFromWork) {
return
}
// Resolve the worktree from the tab (authoritative) rather than the
// agent-echoed worktreeId, which is not present on every hook event.
// Resolve from the tab (authoritative); the agent-echoed worktreeId isn't present on every event.
const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId
const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId
if (!worktreeId) {
@@ -132,37 +118,27 @@ export async function maybeAutoRenameBranchOnFirstWork(
}
inFlightWorktreeIds.add(worktreeId)
try {
// `settled` true means a definitive verdict (renamed or permanently
// ineligible); false means a transient bail that should retry later.
// settled = definitive verdict (renamed/ineligible); false = transient bail to retry later.
const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps)
if (settled) {
rememberSettledWorktreeId(worktreeId)
}
} catch (error) {
// Why: best-effort, opt-in convenience. A failure must never disrupt the
// agent the user actually launched, so swallow after logging.
// Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging.
console.warn('[auto-branch-rename] rename attempt failed:', error)
} finally {
inFlightWorktreeIds.delete(worktreeId)
}
}
/**
* Returns true when a definitive verdict was reached (renamed, or permanently
* ineligible — so stop re-probing), false for a transient bail worth retrying.
*/
/** Returns true for a definitive verdict (renamed or permanently ineligible → stop re-probing), false for a transient bail worth retrying. */
async function runAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps
): Promise<boolean> {
// Why: when the feature appears not to fire, the question is always "which
// gate bailed?" — this names the gate so a single log line answers it.
// `stop` = permanent (don't retry); `retry` = transient (try again later).
// `clearError` clears a stale "rename failed" badge when the worktree reaches
// a benign final state (ineligible, user-renamed, or pushed) after an earlier
// transient generation failure raised one — otherwise it would stick forever.
// `stop` = permanent skip (logs which gate bailed); `retry` = transient; `clearError` drops a stale "rename failed" badge on a benign final state.
const stop = (reason: string, clearError = false): true => {
if (clearError) {
deps.setRenameError(worktreeId, null)
@@ -188,9 +164,7 @@ async function runAutoRename(
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
// Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs
// carry a synthetic `::workspace:<uuid>` suffix that is not a real directory,
// so resolve to the backing folder or Git spawns against a nonexistent cwd.
// Why: folder-workspace ids carry a synthetic `::workspace:<uuid>` suffix that's not a real dir; resolve to the backing folder for Git's cwd.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!repo || !parsed) {
return stop('unresolved repo or worktree id')
@@ -223,8 +197,7 @@ async function runAutoRename(
return stop(`branch "${currentBranch}" already has an upstream`, true)
}
if (upstreamProbe.outcome === 'probe-failed') {
// Why: settling an unreadable probe as "has upstream" made this failure
// silent and permanent via the settled cache (issue #7808).
// Why: settling an unreadable probe as "has upstream" made this silent + permanent via the settled cache (issue #7808).
const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim()
deps.setRenameError(
worktreeId,
@@ -237,8 +210,7 @@ async function runAutoRename(
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo)
if (!resolvedParams.ok) {
// Why: a generation-step failure (vs a benign skip) is user-actionable, so
// surface it on the card rather than leaving a silent "rename pending".
// Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card.
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
@@ -256,17 +228,14 @@ async function runAutoRename(
target
)
if (!generated.success) {
// Transient: the agent may be momentarily busy/unreachable — let a later
// event retry rather than permanently leaving the creature name.
// A user-canceled generation isn't a failure to surface, so skip the badge.
// Transient failure: retry on a later event; skip the badge for a user-canceled generation.
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
}
return retry(`generation failed: ${generated.error}`)
}
// Re-validate after generation (it can take seconds): the branch must be the
// same unpublished creature branch we started from, or we leave it alone.
// Re-validate after generation (takes seconds): bail if the branch changed or was published meanwhile.
const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branchNow !== currentBranch) {
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
@@ -283,14 +252,12 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: (await resolveLocalGitUsername(repo.path)) || null
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip
// it so it doesn't double-prefix the branch or leak into the display name.
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing.
const slug = stripConfiguredBranchPrefix(
generated.slug,
getConfiguredBranchPrefix(settings, username)
)
// Prefix-only model output strips to empty; renaming with it would just
// re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip.
// Prefix-only output strips to empty; renaming would just re-add the prefix (`tmchow/tmchow`), so skip.
if (!slug) {
return stop('model produced only the configured prefix', true)
}
@@ -301,8 +268,7 @@ async function runAutoRename(
currentBranch
)
if (!newBranch || newBranch === currentBranch) {
// Generation succeeded but yielded no distinct name terminal and benign,
// so clear any stale failure badge a prior transient attempt left behind.
// No distinct name is terminal and benign, so clear any stale failure badge from a prior transient attempt.
return stop(`no distinct unique branch name for slug "${slug}"`, true)
}
@@ -310,12 +276,9 @@ async function runAutoRename(
? provider.renameCurrentBranch(worktreePath, newBranch)
: renameCurrentBranch(exec, newBranch))
// resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so
// derive the sidebar name and on-disk folder from the *resolved* branch leaf,
// not the pre-suffix slug, to keep branch, display, and folder aligned.
// resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug.
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
// Keep the sidebar name in sync with the branch — but only when it is still
// the auto-generated creature name, so a name the user typed is left alone.
// Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone.
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf })
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
@@ -326,10 +289,7 @@ async function runAutoRename(
// A successful rename clears any stale generation-failure surfaced earlier.
deps.setRenameError(worktreeId, null)
// Align the on-disk folder with the new branch leaf. Best-effort and local-only:
// a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is
// and must never undo the branch/display rename that already landed. Runs after
// setDisplayName so the new display name rides along into the migrated identity.
// Best-effort local-only folder align, after setDisplayName so the new name rides into the migrated identity; a failure must never undo the landed rename.
let folderRenamed = false
if (deps.renameWorktreeFolder) {
try {
@@ -339,9 +299,7 @@ async function runAutoRename(
}
}
// A successful folder rename already invalidated caches and pushed a
// worktrees:changed carrying the id mapping; a second onRenamed would only
// trigger a redundant renderer re-list. Otherwise notify for the branch rename.
// A folder rename already notified the renderer, so a second onRenamed would just re-list redundantly.
if (!folderRenamed) {
deps.onRenamed(repo.id)
}
+22 -75
View File
@@ -38,16 +38,11 @@ export type HooksConfig = {
[key: string]: unknown
}
// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell
// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a
// hook the agent host itself runs in case that transport budget is bypassed.
// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets
// coexist by design (#4633).
// Why: host-level backstop timeout for status hooks, independent of the curl --max-time and Copilot's timeoutSec (#4633).
export const MANAGED_HOOK_TIMEOUT_SECONDS = 10
export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000
// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude,
// Codex, Gemini, Droid, Grok, Command Code, Devin).
// Nested command hook for the Claude-shaped `hooks: [...]` schema (Claude, Codex, Gemini, Droid, Grok, Command Code, Devin).
export function buildManagedCommandHook(
command: string,
timeout = MANAGED_HOOK_TIMEOUT_SECONDS
@@ -55,8 +50,7 @@ export function buildManagedCommandHook(
return { type: 'command', command, timeout }
}
// Direct command definition used by schemas that put `command` on the
// definition itself (Cursor's documented top-level shape).
// Direct command definition for schemas that put `command` on the definition itself (Cursor's top-level shape).
export function buildManagedCommandDefinition(command: string): HookDefinition {
return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
@@ -78,18 +72,12 @@ export function readHooksJson(configPath: string): HooksConfig | null {
}
}
// Why: callers in install/remove need to match not just the exact current
// managed command, but also stale entries pointing at old script paths — e.g.
// from a previous dev build with a different Electron userData dir, or a
// parallel dev/prod install. Matching by the managed script's file name
// (under any `agent-hooks/` directory) lets a fresh install sweep those
// without touching unrelated user-authored hooks.
// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs.
export function createManagedCommandMatcher(
scriptFileName: string
): (command: string | undefined) => boolean {
const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '')
// Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX
// installs use .sh. A platform switch must still sweep stale Orca hooks.
// Why: installs use .cmd/.ps1 (Windows) or .sh (SSH/POSIX); match all so a platform switch still sweeps stale hooks.
const needles = [
`agent-hooks/${scriptFileName}`,
`agent-hooks/${scriptStem}.cmd`,
@@ -119,8 +107,7 @@ function decodePowerShellEncodedCommand(command: string): string | null {
}
}
// Why: prod, dev, and parallel Orca instances must write the same managed
// settings entry instead of racing between per-userData script paths.
// Why: prod/dev/parallel Orca instances must write the same managed entry, not race between per-userData script paths.
export function getSharedManagedScriptPath(scriptFileName: string): string {
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
}
@@ -129,16 +116,9 @@ function quotePosixShellString(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
// Why: a stale managed hook entry (left over after the user wiped userData,
// switched dev↔prod installs, or had a partial install fail) used to fire
// `/bin/sh "<missing path>"` on every tool call, which exits 127 and surfaces
// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
// transcript. Guarding for a regular readable executable file makes a broken
// install a silent no-op without hiding failures from a script that starts.
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
// literally — avoids a shell-injection footgun if a future caller passes an
// arbitrary path.
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
const quoted = quotePosixShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
@@ -162,8 +142,7 @@ export function wrapWindowsHookCommand(
scriptPath: string,
env: Record<string, string> = {}
): string {
// Why: the encoded launcher protects paths across Windows hook shells and
// owns stdin when a stale config points at a missing managed script.
// Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script.
const quoted = quotePowerShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
@@ -176,14 +155,7 @@ export function wrapWindowsHookCommand(
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
// Why: Codex/Antigravity/Devin launch the hook command as a program (argv[0]),
// NOT through cmd.exe, so the launcher must be a single directly-spawnable
// token. The bare .cmd path is exactly that. A cmd-builtin `if exist …`
// launcher is unspawnable — its argv[0] is `if` — so it fails every hook with
// exit 1 (#8430 regression). A stale/missing script therefore surfaces a normal
// launch failure here; the missing-script stdin drain lives on the encoded
// fallback below, which needs a real interpreter for spaces/metacharacters
// anyway and cannot be reached by a cmd-builtin drain without breaking spawn.
// Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430).
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
@@ -191,17 +163,15 @@ export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
const bashPath = scriptPath.replaceAll('\\', '/')
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly;
// unsafe paths stay encoded and the fast path gains a missing-file drain.
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
: wrapWindowsHookCommand(scriptPath)
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
// Qualify curl so a repo-local curl.exe cannot hijack hook payloads.
// Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path.
// Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads.
return [
`"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`,
' --connect-timeout 0.5 --max-time 1.5 ^',
@@ -217,12 +187,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin
].join('\r\n')
}
// Why: status hooks fire up to 6× per turn; spawning PowerShell per post adds
// ~300ms of interpreter startup each, which Codex 0.140's synchronous "Running
// <event> hook" rows make visible. curl.exe (Windows 10 1803+) posts the same
// form fields as the POSIX hook and reads the raw payload from stdin via
// `--data-urlencode payload@-`, so UTF-8 (e.g. CJK prompts) survives byte-for-
// byte without the code-page translation that previously forced PowerShell.
// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both.
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
return [
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
@@ -294,8 +259,7 @@ export function hookDefinitionHasManagedCommand(
)
}
// Why: temp+rename so concurrent Orca instances writing this shared path can't
// produce a torn script that an in-flight `/bin/sh <scriptPath>` would source.
// Why: temp+rename so concurrent writers can't leave a torn script for an in-flight /bin/sh to source.
export function writeManagedScript(scriptPath: string, content: string): void {
const dir = dirname(scriptPath)
mkdirSync(dir, { recursive: true })
@@ -316,9 +280,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeScriptWithAclRetry(tmpPath, content)
// Why: chmod before rename so the canonical path is never visible in a
// unreadable/non-executable state; wrapPosixHookCommand's guards would
// silently skip the hook in that window.
// Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook.
if (process.platform !== 'win32') {
chmodSync(tmpPath, 0o755)
}
@@ -334,8 +296,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
}
}
// Why: on Windows, write may fail with EPERM if the target directory has a
// restrictive DACL. Grant an explicit ACL on EPERM and retry once.
// Why: a restrictive directory DACL makes writes fail with EPERM on Windows; grant an ACL and retry once.
function writeScriptWithAclRetry(scriptPath: string, content: string): void {
try {
writeFileSync(scriptPath, content, 'utf-8')
@@ -357,39 +318,25 @@ export function writeHooksJson(configPath: string, config: HooksConfig): void {
const dir = dirname(configPath)
mkdirSync(dir, { recursive: true })
// Why: write to a temp file then rename so a crash or disk-full mid-write
// leaves the original untouched. This is the only safe way to update a
// config file the user may have hand-edited.
//
// Why randomUUID: Date.now() alone collides when two install() calls fire in
// the same millisecond targeting the same dir (e.g. a future caller that
// installs multiple agents sharing a config dir, or rapid reinstalls from
// the settings UI). A collision would corrupt one of the two writes. The
// UUID suffix makes the tmp path unique per call.
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
const serialized = `${JSON.stringify(config, null, 2)}\n`
// Why: skip the write (and therefore the .bak rotation) when the on-disk
// content is already identical. Without this, every install() rewrites the
// file and rolls the backup forward, which can silently destroy the last
// recoverable copy if install() is called repeatedly (e.g. on app start).
// Why: skip identical writes so repeated install() calls don't roll the .bak forward and destroy the last recoverable copy.
if (existsSync(configPath)) {
try {
if (readFileSync(configPath, 'utf-8') === serialized) {
return
}
} catch {
// Fall through to the normal write path a read error here is not
// worth failing the install for; the atomic write below will either
// succeed or throw loudly.
// Fall through to the normal write path; a read error isn't worth failing the install for.
}
}
try {
writeFileSync(tmpPath, serialized, 'utf-8')
// Why: single rolling backup — one file, no accumulation in ~/.claude.
// Protects against a merge-logic bug producing bad JSON; the original is
// always recoverable from <configPath>.bak until the next write.
// Why: single rolling backup so a merge-logic bug producing bad JSON stays recoverable from <configPath>.bak until the next write.
if (existsSync(configPath)) {
copyFileSync(configPath, `${configPath}.bak`)
}
+34 -117
View File
@@ -157,9 +157,7 @@ describe('AgentHookServer listener replay', () => {
state: 'working',
prompt: 'review loop',
agentType: 'claude',
// Why: a working pane can be child-driven (lead already idle).
// Ctrl+C does not stop background children, so no terminal done
// may be inferred while one is still running.
// Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here.
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }]
}
},
@@ -2571,9 +2569,7 @@ describe('AgentHookServer listener replay', () => {
}
})
// Why: agent-status-over-SSH §3 ingestRemote must run the same warn-once
// cross-build diagnostics the local HTTP path runs, so a remote source of
// genuinely stale hooks emits the same signal locally.
// Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally.
it('runs warn-once env/version diagnostics on relay-forwarded events', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -2631,9 +2627,7 @@ describe('AgentHookServer listener replay', () => {
'conn-1'
)
expect(warn.mock.calls.length).toBe(warnsAfterFirst)
// Why: pin both invariants — warn-once dedupe AND fanout still fires for
// the second event. Without the second assertion, a future refactor that
// drops the second event silently would still leave warn-count unchanged.
// Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone.
expect(listener).toHaveBeenCalledTimes(2)
} finally {
server.stop()
@@ -3843,9 +3837,7 @@ describe('Claude hook normalization', () => {
})
it('PostToolUse for an unknown tool surfaces the name without input', () => {
// Why: we use a per-tool allowlist to decide which field to preview.
// Tools we do not recognize render as name-only rather than guessing at
// a field, which avoids noisy/misleading previews (e.g. an opaque ID).
// Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID).
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
@@ -3860,9 +3852,7 @@ describe('Claude hook normalization', () => {
})
it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => {
// Why: TaskUpdate's tool_input (e.g. { task_id: "3", status: "in_progress" })
// has no meaningful preview — rendering "3" is actively confusing. The
// allowlist approach leaves toolInput undefined for unlisted tools.
// Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined.
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
@@ -4144,9 +4134,7 @@ describe('Claude hook normalization', () => {
})
it('finds an assistant reply that sits past the first chunk boundary', () => {
// Why: a turn with many large tool_result entries pushes the final text
// reply well past the first 64 KB chunk; the chunked scan should keep
// reading backward until it finds it.
// Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it.
const filler = 'x'.repeat(70_000)
const lines = [
{ role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } },
@@ -4208,8 +4196,7 @@ describe('Claude hook normalization', () => {
}),
'production'
)
// Stop event has no tool fields of its own — merged snapshot should still
// carry the earlier PreToolUse values.
// Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values.
const stop = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop' }),
@@ -4236,10 +4223,7 @@ describe('Codex hook normalization', () => {
})
it('PreToolUse surfaces tool name + input preview and stays in working state', () => {
// Why: Codex's PreToolUse is NOT an approval prompt — it fires for every
// tool call. We map it to `working` (never `waiting`) and use it only to
// give the dashboard a live readout during the gap between prompt and
// Stop. Real approval signals flow through PermissionRequest.
// Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4255,10 +4239,7 @@ describe('Codex hook normalization', () => {
})
it('PermissionRequest maps to waiting and surfaces the pending tool input', () => {
// Why: Codex asks for user attention through PermissionRequest. Orca's
// sidebar red dot depends on this becoming `waiting`; treating it like
// PreToolUse would leave the pane looking busy while it is blocked on the
// user.
// Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4275,9 +4256,7 @@ describe('Codex hook normalization', () => {
})
it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => {
// Why: UserPromptSubmit is a turn-boundary event; any tool_name on it
// would be leftover noise and should not leak into the working-state
// preview. Tool extraction is gated to PreToolUse/PostToolUse.
// Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4419,11 +4398,7 @@ describe('OpenCode hook normalization', () => {
})
it('SessionBusy does NOT clear the cached user prompt', () => {
// Why: OpenCode emits the user's MessagePart (message.updated) *before*
// SessionBusy fires — the session goes idle→busy only after OpenCode begins
// processing the prompt. So the cached prompt at SessionBusy is the current
// turn's prompt, not the previous turn's. Clearing on SessionBusy would
// clobber the data the dashboard needs to render for this turn.
// Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard.
_internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }),
@@ -4458,12 +4433,7 @@ describe('OpenCode hook normalization', () => {
})
it('AskUserQuestion maps to waiting', () => {
// Why: OpenCode emits `question.asked` when the agent uses an ask-the-user
// tool (distinct from `permission.asked`, which blocks on tool approval).
// Both leave the agent idle-but-waiting on a human, so both must render
// the same red "needs attention" indicator. Without this mapping the pane
// silently stays in `working` and the user has no visual cue that the
// agent is waiting on them.
// Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`.
const result = _internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'AskUserQuestion' }),
@@ -4514,10 +4484,7 @@ describe('OpenCode hook normalization', () => {
})
it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => {
// Why: plugin builds installed before the throttle/cap fix re-post the
// full accumulated reply on every streamed part update. The listener must
// bound the text so each event's status compare, IPC fanout, and renderer
// store update stay O(cap) instead of O(reply length).
// Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap).
const assistant = _internals.normalizeHookPayload(
'opencode',
buildBody({
@@ -4529,9 +4496,7 @@ describe('OpenCode hook normalization', () => {
)
expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000)
// Why: prompt has always been single-line-capped at 200 by
// normalizeAgentStatusObject; this asserts the oversized input still
// flows through without blowing past that bound.
// Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound.
const user = _internals.normalizeHookPayload(
'opencode',
buildBody({
@@ -4656,8 +4621,7 @@ describe('Cursor hook normalization', () => {
}),
'production'
)
// Why: keeping toolName set would let the compact sidebar show the tool
// instead of the failure text, hiding the error from the user.
// Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'file not found'
@@ -5193,8 +5157,7 @@ describe('Pi hook normalization', () => {
buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }),
'production'
)
// Why: pi captures the user prompt via before_agent_start, not via
// message_end. A user-role message_end should not flip lastAssistantMessage.
// Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage.
expect(result?.payload.lastAssistantMessage).toBeUndefined()
})
@@ -5214,8 +5177,7 @@ describe('Pi hook normalization', () => {
buildBody({ hook_event_name: 'session_shutdown' }),
'production'
)
// Why: Pi also emits shutdown when reloading or replacing its in-process
// session while the PTY stays alive; only agent_end proves turn completion.
// Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion.
expect(result).toBeNull()
})
@@ -5324,8 +5286,7 @@ describe('Copilot hook normalization', () => {
}),
'production'
)
// Why: keeping toolName set would let the compact sidebar show the tool
// instead of the failure text, hiding the error from the user.
// Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'command not found'
@@ -5715,9 +5676,7 @@ describe('Endpoint file lifecycle', () => {
await server.start({ env: 'production', userDataPath })
try {
const filePath = server.endpointFilePath!
// Why: mask off type/setuid bits so we assert only the rwx octet that
// writeFileSync(mode:0o600) sets. A leaky umask at dir-create time can
// leave group/other bits on the *parent* dir but not on the file itself.
// Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir.
const mode = statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
} finally {
@@ -5739,19 +5698,11 @@ describe('Endpoint file lifecycle', () => {
const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN
// Path is stable (so PTYs stamped before restart can still find the file)
expect(secondPath).toBe(firstPath)
// But contents are refreshed with the new token (and port) — that is the
// whole point of the design: survivors reading a stale-env file reach the
// live server. Why token-first: the token is randomUUID()-minted per
// start(), so it is guaranteed to differ across restarts. The port comes
// from listen(0) and the kernel can legitimately reassign the same
// ephemeral port, so asserting port-inequality would be a latent flake.
// Contents refresh with a new token so stale-env survivors reach the live server.
expect(secondToken).toBeTruthy()
expect(secondToken).not.toBe(firstToken)
const contents = readFileSync(secondPath!, 'utf8')
// Why: token-based content check is the rewrite signal. A strict
// "contents does NOT contain firstPort" assertion would flake on the
// (rare but legitimate) case where listen(0) reuses the same ephemeral
// port across restarts. The token is randomUUID() and cannot collide.
// Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check.
expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`)
expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`)
expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`)
@@ -5761,13 +5712,7 @@ describe('Endpoint file lifecycle', () => {
})
it('leaves the endpoint file in place on stop()', async () => {
// Why: stop() deliberately does NOT unlink the endpoint file. A stale file
// points at a dead port — the fail-open path (hook POSTs silently fail,
// same as pre-endpoint-file). Unlinking would introduce a TOCTOU race with a
// concurrent Orca instance sharing userData that could rewrite the file
// between our token check and unlink. The next successful start()
// overwrites the file atomically; tmp-file orphan hygiene is handled by
// the sweep inside writeEndpointFile().
// Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU).
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
const filePath = server.endpointFilePath!
@@ -5825,9 +5770,7 @@ describe('Endpoint file lifecycle', () => {
})
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => {
// Why: the endpoint file is opt-in via start({ userDataPath }). In tests
// and in the packaged main-process path where userData is unset for any
// reason, hooks should fall back to the v1 behavior (no ENDPOINT key).
// Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key).
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
@@ -5846,10 +5789,7 @@ describe('Endpoint file lifecycle', () => {
})
it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => {
// Why: writeEndpointFile() writes to a unique tmp path then renames. A crash
// between write and rename leaves an orphan tmp; the sweep inside
// writeEndpointFile() must drop ones older than 5 min without touching
// fresh ones (a concurrent writer's in-flight tmp).
// Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp.
const dir = join(userDataPath, 'agent-hooks')
mkdirSync(dir, { recursive: true })
const staleTmp = join(dir, '.endpoint-999-stale.tmp')
@@ -5870,12 +5810,7 @@ describe('Endpoint file lifecycle', () => {
})
it('refuses to write the endpoint file when a value contains shell metacharacters', async () => {
// Why: every value written is sourced as shell. The isShellSafeEndpointValue
// allowlist must reject a metacharacter-bearing value so a future caller
// cannot command-inject via the sourced file. `env` is the only caller-
// provided field we can easily poison from a test — feed it a semicolon
// and assert the file is not written and buildPtyEnv() omits the ENDPOINT
// key (gated on endpointFileWritten).
// Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection.
const server = new AgentHookServer()
await server.start({ env: 'bad;value', userDataPath })
try {
@@ -5960,10 +5895,7 @@ describe('Endpoint file lifecycle', () => {
try {
const filePath = server.endpointFilePath!
const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT
// Why: sources the file in a subshell and echoes the resulting env var,
// exactly as the managed hook script does at runtime. If the file shape
// ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters
// without quoting), this test catches it before users do.
// Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do.
const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`])
.toString()
.trim()
@@ -5989,9 +5921,7 @@ describe('Last-status persistence', () => {
return join(userDataPath, 'agent-hooks', 'last-status.json')
}
// Why: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use
// a recent-but-not-Date.now() timestamp in fixtures so the tests assert
// hydration behavior rather than racing the wall clock.
// Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock.
function recentTs(offsetMs = 0): number {
return Date.now() - 60 * 60 * 1000 + offsetMs
}
@@ -6305,8 +6235,7 @@ describe('Last-status persistence', () => {
subagents: undefined
})
])
// Why: make the migration one-time; otherwise every launch reparses and
// re-prunes the same persisted idle rows.
// Why: migration must be one-time, else every launch re-prunes the same persisted idle rows.
const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(persisted.entries[PANE].payload.subagents).toBeUndefined()
} finally {
@@ -6614,9 +6543,7 @@ describe('Last-status persistence', () => {
entries: {
[TAB_A_PANE]: {
paneKey: TAB_A_PANE,
// Why: deliberately divergent — paneKey says tab-A, the entry
// claims tab-B. Sanitizer must drop rather than hydrate this
// inconsistent row.
// Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it.
tabId: 'tab-B',
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
@@ -6676,14 +6603,10 @@ describe('Last-status persistence', () => {
server.flushStatusPersistSync()
const firstMtime = statSync(lastStatusPath()).mtimeMs
// Why: a no-op clearPaneState on a paneKey not in the cache is a
// mutation site that should NOT trigger a redundant write. (clear was
// designed to bail when nothing was evicted.)
// Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted).
server.clearPaneState(makePaneKey('non-existent', LEAF_5))
server.flushStatusPersistSync()
// Touch back to the same mtime would let the test pass spuriously, so
// assert no rewrite happened by checking that mtime is unchanged after
// a forced sync flush.
// Assert no rewrite happened: mtime unchanged after a forced sync flush.
const secondMtime = statSync(lastStatusPath()).mtimeMs
expect(secondMtime).toBe(firstMtime)
} finally {
@@ -6706,8 +6629,7 @@ describe('Last-status persistence', () => {
} finally {
server.stop()
}
// Why: file written even though we never explicitly flushed before stop —
// stop() must synchronously drain the pending trailing-debounced timer.
// Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed.
expect(existsSync(lastStatusPath())).toBe(true)
const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me')
@@ -7114,9 +7036,7 @@ describe('AgentHookServer ingestRemote', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
// Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
// constructing an obviously-invalid payload — `ingestRemote` is the trust
// boundary we're testing, not the parser.
// Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser.
server.ingestRemote(
{
paneKey: PANE,
@@ -7281,10 +7201,7 @@ describe('AgentHookServer ingestRemote', () => {
})
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
// Why: the relay normally normalizes the payload on the wire, but a buggy
// or malicious relay could forward an over-cap field. ingestRemote must
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
// cap (200 chars) is enforced at the trust boundary.
// Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary.
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
+82 -263
View File
@@ -1,15 +1,5 @@
/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */
// Why: this module is the Orca-main-process adapter for the shared
// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The
// listener internals (request parsing, payload normalization, endpoint-file
// writing, validation) live in `shared/` so the relay can host the same
// pipeline on the remote without dragging Electron in. This file owns:
// - the loopback HTTP socket + bearer-token auth
// - the IPC fanout (setListener / lastStatusByPaneKey replay)
// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded
// events (see docs/design/agent-status-over-ssh.md §5)
// - the on-disk last-status cache (`last-status.json`) that survives
// Orca restart so retained dashboard rows reappear on relaunch
// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -74,14 +64,7 @@ import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn
export type { AgentHookSource }
// Why: server-process-only enrichment of the shared event payload. The shared
// listener emits `AgentHookEventPayload` (the bare event shape). For
// persistence and the dashboard's "did the agent transition since I last
// looked?" comparison, we attach `receivedAt` (when the latest event arrived
// for this pane) and `stateStartedAt` (when the current state first appeared).
// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload`
// is the declared map value, and the extra fields ride along untouched because
// the shared module only writes/clears, never reads.
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
@@ -103,42 +86,24 @@ type PaneKeyAliasEntry = {
authorityVerified: boolean
}
// Why: name of the on-disk cache that survives Orca restart. Lives next to
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
// restart artifacts stay co-located.
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
// developer who upgrades from an in-flight branch sees an empty hydration
// once instead of partially-typed legacy entries. New file format; never
// shipped to users at v1. A mismatched version is treated as a corrupt file
// (silent empty hydration).
// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
const LAST_STATUS_FILE_VERSION = 2
// Why: trailing-edge debounce so a burst of hook events from a multi-agent
// run produces one disk write instead of N. The latency budget matches other
// hook-server batching; quit-time uses flushStatusPersistSync() for the
// guaranteed final flush.
// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush.
const STATUS_PERSIST_DEBOUNCE_MS = 250
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// Why: bound the on-disk file's growth across many sessions. PTY-teardown
// eviction handles closed panes, but daemon-restored PTYs that never re-attach
// and crash-recovery paths where teardown never fires can leave entries
// pinned forever. 7 days matches the user-visible "still relevant?" horizon —
// older entries have almost certainly been resolved or abandoned and should
// not resurrect on hydrate.
// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate.
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
// Why: closed-tab suppression only needs to cover recently closed tabs — a
// status event for a long-closed tab cannot arrive once its process/hooks are
// gone. Bound the set so it can't grow one entry per tab close for the whole
// session (it is otherwise only cleared at app quit).
// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session.
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
export const PANE_KEY_ALIASES_MAX = 1024
@@ -176,8 +141,7 @@ function equivalentInterruptAgentType(
return normalizedActual === normalizedBaseline
}
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
// at write/hydrate time so legacy numeric rows fail closed.
// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
return (
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
@@ -194,16 +158,14 @@ function dropHydratedIdleClaudeSubagents(
return payload
}
const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working')
// Why: older builds persisted finished Claude children as idle rows. Prune
// them from the replay payload itself so restart cannot resurrect the pile.
// Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile.
return {
...payload,
subagents: workingSubagents.length > 0 ? workingSubagents : undefined
}
}
// Why: the sole gate deciding whether a providerSessionOnly row is trustworthy
// enough to keep. Shared so the hydrate and relay-ingest paths can't drift.
// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift.
function isValidPiProviderSessionOnly(
providerSession: AgentProviderSessionMetadata | undefined,
agentType: AgentType | undefined
@@ -230,9 +192,7 @@ function sanitizeHydratedEntry(
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
return null
}
// Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field
// diverges from the key's tab segment is corruption (renamer bug, manual
// edit, future shape drift). Drop instead of hydrating an inconsistent row.
// Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row.
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
return null
}
@@ -252,8 +212,7 @@ function sanitizeHydratedEntry(
) {
return null
}
// Why: connectionId is allowed to be null (local) or string (relay). Any
// other shape is rejected so the post-merge typed surface stays honest.
// Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest.
const connectionIdRaw = record.connectionId
let connectionId: string | null
if (connectionIdRaw === null || connectionIdRaw === undefined) {
@@ -307,10 +266,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
}
}
// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`:
// OSC payloads never carry them, and including the field would make every
// hook-cached entry with child rows non-equivalent — the OSC ping would then
// apply and wipe the roster. Do not reuse this for hook-path comparisons.
// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons.
function equivalentParsedAgentStatusPayload(
a: ParsedAgentStatusPayload,
b: ParsedAgentStatusPayload
@@ -345,8 +301,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole
if (next.payload.agentType !== 'claude') {
return false
}
// Why: a same-prompt retry is another UserPromptSubmit, while late Claude
// progress after Ctrl+C arrives as tool lifecycle work for the old turn.
// Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work.
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
}
@@ -378,10 +333,7 @@ function shouldKeepClaudePermissionVisible(
if (isClaudePermissionResumingApprovedTool(previous, next)) {
return false
}
// Why: only real permission requests stay sticky across concurrent subagent
// activity; interactive questions clear on the next working hook. Newer
// Claude reports the AskUserQuestion wait AS a PermissionRequest, so the
// tool name — not the event name — decides which rule applies.
// Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides.
if (isAskUserQuestionTool(previous.payload.toolName)) {
return false
}
@@ -427,10 +379,7 @@ function isClaudePermissionResumingApprovedTool(
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
nextToolUseId !== undefined &&
!hasConflictingToolUseId &&
// Why: subagents can share `agent_type`; a concrete agent id is the
// strongest available signal that the permission owner resumed execution.
// Claude's approval path omits identity but preserves the original
// tool_use_id on PostToolUse, so that exact id is also a safe clear signal.
// Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal.
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
sameToolName &&
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
@@ -484,8 +433,7 @@ function attachClaudePermissionToolUseId(
}
return {
...next,
// Why: Claude emits PermissionRequest without tool_use_id, then reports the
// approved command as PostToolUse with the original PreToolUse id.
// Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id.
toolUseId: inheritedToolUseId
}
}
@@ -494,35 +442,24 @@ export class AgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
private token = ''
// Why: identifies this Orca instance so hook scripts can stamp requests and
// the server can detect dev vs. prod cross-talk. Set at start() from the
// caller's knowledge of whether this is a packaged build.
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
private env = 'production'
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
private onPaneStatusCleared: PaneStatusClearListener | null = null
private statusChangeListeners = new Set<StatusChangeListener>()
// Why: directory that holds the on-disk endpoint file. Set via start()'s
// `userDataPath` option so the class has no direct Electron dependency
// (keeps it mockable in the vitest node environment).
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
private endpointDir: string | null = null
private endpointFilePathCache: string | null = null
private endpointFileWritten = false
// Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus
// by paneKey). Held on the instance instead of as module-level Maps so
// tests can spin up multiple servers without state cross-contamination.
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
private state: HookListenerState = createHookListenerState()
// Why: hydrated last-status rows are useful UI continuity, but they are not
// evidence of live agent work in this main-process runtime.
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
private runtimeObservedStatusPaneKeys = new Set<string>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: full path to the on-disk last-status cache. Set in start() from
// userDataPath. Null when the server runs without a userDataPath (e.g.
// tests that skip the userDataPath option) — in that case, persistence is
// a no-op and only in-memory replay applies.
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
private lastStatusFilePath: string | null = null
// Why: trailing-edge debounce timer. Captured per-instance so multiple
// server instances in the same process (tests) don't share state.
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
@@ -530,9 +467,7 @@ export class AgentHookServer {
private closedAgentStatusTabIds = new Set<string>()
private closedAgentStatusPaneKeys = new Set<string>()
private connectionTimestampWatermarkById = new Map<string, number>()
// Why: identity check — skip writes when the JSON-stringified contents
// exactly match the last successful disk write. Cheap protection against
// re-firing trailing timers when nothing changed.
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
@@ -540,15 +475,10 @@ export class AgentHookServer {
if (!listener) {
return
}
// Why: replay is best-effort per pane so one throwing listener call can't
// starve subsequent panes from being replayed.
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
for (const payload of this.state.lastStatusByPaneKey.values()) {
try {
// Why: cache values are stored as enriched payloads (with receivedAt /
// stateStartedAt). The map's declared element type from the shared
// listener is the bare AgentHookEventPayload because the shared module
// never reads from this map; only this class does, and only enriched
// values are ever inserted.
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
@@ -567,10 +497,8 @@ export class AgentHookServer {
this.onPaneStatusCleared = listener
}
/** Snapshot of the current cached statuses, in the IPC-shaped form the
* renderer consumes. Used by the `agentStatus:getSnapshot` IPC after
* workspace tabs have hydrated, so the dashboard catches up on any
* hook events that fired during startup. */
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
* dashboard catches up on hook events that fired during startup. */
getStatusSnapshot(): AgentStatusIpcPayload[] {
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
@@ -595,14 +523,11 @@ export class AgentHookServer {
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
// Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C
// exits the CLI, which is handled by process/PTY lifecycle cleanup.
// Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
if (agentType === 'droid' && request.intent === 'ctrl-c') {
return false
}
// Why: these agents use the first Escape as a TUI/editor cancel. A single
// Escape can leave the turn running, so only a deliberate double Escape
// may infer an interrupted turn.
// Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt.
if (
(agentType === 'opencode' || agentType === 'copilot') &&
request.intent === 'plain-escape' &&
@@ -610,9 +535,7 @@ export class AgentHookServer {
) {
return false
}
// Why: input-intent inference is a fallback for a missing final hook. A strict
// baseline match keeps a delayed timer from overwriting any newer hook,
// including same-millisecond prompt or agent identity changes.
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
@@ -623,17 +546,12 @@ export class AgentHookServer {
) {
return false
}
// Why: a 'working' pane can be child-driven (lead already idle, background
// subagent running). Ctrl+C at the TUI does not stop background children,
// so inferring a terminal done here would wrongly retire live child rows;
// their own hook events keep the row truthful instead.
// Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
if (payload.subagents?.some((subagent) => subagent.state === 'working')) {
return false
}
// Why: keep the listener's Claude lead-turn record in sync a later
// child lifecycle event would otherwise re-emit the stale pre-interrupt
// 'working' lead state and resurrect the cancelled pane.
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
}
@@ -648,8 +566,7 @@ export class AgentHookServer {
prompt: payload.prompt,
agentType,
interrupted: true,
// Why: idle children are display state; dropping them on an inferred
// interrupt would blank the child rows a later hook would restore.
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
@@ -661,11 +578,8 @@ export class AgentHookServer {
return true
}
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion
* produces no event, so the amber wait would otherwise linger until the
* agent's next tool or turn end. The renderer reports the submit keystroke;
* this re-validates its baseline against the cached status (a racing real
* hook wins) and synthesizes the post-answer state. */
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
* renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
@@ -677,12 +591,7 @@ export class AgentHookServer {
return false
}
const payload = existing.payload
// Why: only Claude's interactive question may clear on typed input. The
// tool name is the discriminator, not the hook event — Claude versions
// differ on whether the AskUserQuestion wait arrives as PreToolUse or
// PermissionRequest. Real permission waits (other tools) stay sticky until
// the approved tool resumes — a denied or ignored permission must keep
// demanding attention even though approving is also a keystroke.
// Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
if (
payload.agentType !== 'claude' ||
payload.state !== 'waiting' ||
@@ -699,9 +608,7 @@ export class AgentHookServer {
) {
return false
}
// Why: sync the listener's lead-turn record too a later child lifecycle
// event would otherwise re-emit the stale waiting state and resurrect the
// dismissed question card.
// Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card.
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
@@ -754,8 +661,7 @@ export class AgentHookServer {
}
private markTabClosedForAgentStatus(tabId: string): void {
// Delete-then-add keeps recently closed tabs most-recent so eviction only
// sheds the oldest ids, which can no longer receive status events.
// Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids.
this.closedAgentStatusTabIds.delete(tabId)
this.closedAgentStatusTabIds.add(tabId)
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
@@ -881,8 +787,7 @@ export class AgentHookServer {
promptInteractionKey
})
try {
// Why: hooks prove the user submitted a turn, but do not know which UI
// launched the terminal; keep attribution low-cardinality and conservative.
// Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality.
track('agent_prompt_sent', {
agent_kind: agentKind,
launch_source: 'unknown',
@@ -901,15 +806,13 @@ export class AgentHookServer {
const connectionClearWatermark = payload.connectionId
? this.connectionTimestampWatermarkById.get(payload.connectionId)
: undefined
// Why: Date.now() can repeat across disconnect and reconnect. A remote
// replay must sort strictly after its connection's transient clear.
// Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear.
const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1)
if (payload.connectionId) {
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
}
if (payload.providerSessionOnly) {
// Why: Pi session_start must replace stale turn state and survive snapshot
// replay, but it must not emit prompt telemetry or a fabricated status.
// Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
const enriched = this.attachStatusTiming(payload, now)
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
@@ -953,8 +856,7 @@ export class AgentHookServer {
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
return previous
}
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
// stopped the turn. Do not let that stale same-turn event resurrect the row.
// Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row.
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
@@ -1023,8 +925,7 @@ export class AgentHookServer {
if (!discoveryReady) {
const discovery = preparePendingGrokResultDiscovery(source, body)
if (discovery) {
// Why: slug-group discovery can outlive the bounded transcript-flush
// timers. Its completion must drive the first retry deterministically.
// Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically.
void discovery
.then(() => {
if (this.server) {
@@ -1075,8 +976,7 @@ export class AgentHookServer {
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
return
}
// Why: some agents POST Stop before their transcript/chat-history line is
// flushed. Discovery is event-driven; subsequent content retries stay timed.
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
this.applyNormalizedStatus(normalized)
}
@@ -1105,8 +1005,7 @@ export class AgentHookServer {
private boundPaneKeyAliases(): void {
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
// Why: renderer-originated aliases are untrusted process-lifetime state;
// insertion-order eviction bounds both memory and per-message cleanup.
// Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup.
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
if (!oldestKey) {
break
@@ -1150,8 +1049,7 @@ export class AgentHookServer {
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
)
}
// Why: hook status is renderer-originated evidence, not PTY ownership.
// ID-less moves are safe only after a prior verified transfer minted an alias.
// Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias.
return alias?.authorityVerified === true
}
@@ -1233,8 +1131,7 @@ export class AgentHookServer {
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
}
this.clearAssistantMessageRetry(previousOwnerPaneKey)
// Why: the live process keeps posting the physical source key after detach;
// persist one chain-safe mapping to whichever surface currently owns it.
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
this.legacyPaneKeyAliases.set(physicalPaneKey, {
stablePaneKey: toPaneKey,
ptyId: normalizedPtyId,
@@ -1298,9 +1195,7 @@ export class AgentHookServer {
clearedStatusPaneKeys.add(entry.stablePaneKey)
}
if (shouldClearStablePaneKey) {
// Why: after hydrate, legacy rows are stored under the stable key. If
// this PTY is later proven dead before ptyPaneKey is rebuilt, alias
// cleanup is the only path that can evict that retained status.
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
@@ -1334,8 +1229,7 @@ export class AgentHookServer {
if (!stablePaneKey) {
return body
}
// Why: migrated and detached shells keep posting an immutable physical
// pane key; normalize both pane and tab identity to the current owner.
// Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner.
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
}
@@ -1388,8 +1282,7 @@ export class AgentHookServer {
) {
return
}
// Why: OSC terminal status is a runtime/model observation, not a hook
// prompt boundary. Keep prompt-sent telemetry tied to native hooks.
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
this.applyNormalizedStatus({
paneKey,
tabId,
@@ -1399,15 +1292,7 @@ export class AgentHookServer {
})
}
/** Ingest a payload that arrived over the relay JSON-RPC channel rather
* than the local HTTP server. `connectionId` is the SshChannelMultiplexer
* identity Orca holds (the wire envelope carries connectionId: null and
* Orca stamps the real value here). The relay has already normalized the
* payload via the shared listener module, but main is still the SSH trust
* boundary: re-run the canonical status normalizer before caching or
* persisting anything. The `env`/`version` fields are forwarded verbatim
* from the agent CLI's POST body on the remote and validated here so the
* warn-once diagnostics fire for real cross-build mismatches. */
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
ingestRemote(
envelope: {
paneKey: string
@@ -1429,9 +1314,7 @@ export class AgentHookServer {
},
connectionId: string
): void {
// Why: signature says non-empty, but the wire crosses a trust boundary —
// re-check at runtime (and trim) so a whitespace-only or empty
// connectionId can't poison caches.
// Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
if (typeof connectionId !== 'string') {
return
}
@@ -1442,9 +1325,7 @@ export class AgentHookServer {
if (!envelope || typeof envelope.paneKey !== 'string') {
return
}
// Why: match the listener's HTTP path — `normalizeHookPayload` trims and
// length-caps paneKey before caching, so the cache key here must follow
// the same rule or remote-vs-local events for the same pane would diverge.
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
const physicalPaneKey = envelope.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
@@ -1464,9 +1345,7 @@ export class AgentHookServer {
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
return
}
// Why: mirror the HTTP path's `readStringField` behavior — trim and treat
// empty-after-trim as undefined rather than letting a literal "" leak
// into the event.
// Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
const reportedTabId =
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
? envelope.tabId.trim()
@@ -1508,12 +1387,7 @@ export class AgentHookServer {
? envelope.toolAgentType.trim()
: undefined
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
// Why: the relay is across a trust boundary; re-run the canonical
// normalizer on the inner payload so prompt/agentType/toolName/toolInput
// length caps, embedded-newline collapse, and the `interrupted`-only-on-
// done invariant are enforced here too. Returns null on malformed input
// (including invalid state), which subsumes the prior explicit state
// check.
// Why: relay crosses a trust boundary re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
const normalizedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!normalizedPayload) {
return
@@ -1524,9 +1398,7 @@ export class AgentHookServer {
) {
return
}
// Why: run the same warn-once diagnostics the HTTP path runs (cross-build
// version mismatch, dev-vs-prod env mismatch). Use `this.env` as the
// expected env so the messages match what the local server produces.
// Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
warnOnHookEnvOrVersionMismatch(this.state, {
version: envelope.version,
env: envelope.env,
@@ -1565,9 +1437,7 @@ export class AgentHookServer {
this.env = options.env
}
if (options?.userDataPath) {
// Why: dev builds share one userData path, so callers can namespace the
// endpoint file by dev instance while packaged builds keep the stable path
// that lets long-lived PTYs reconnect after app restart.
// Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
this.endpointDir = options.endpointNamespace
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
: join(options.userDataPath, 'agent-hooks')
@@ -1577,10 +1447,7 @@ export class AgentHookServer {
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
// Why: hydrate before binding the HTTP listener so any new hook POST
// (which goes through state.lastStatusByPaneKey.set) runs against an
// already-populated map. The renderer later pulls this map as a snapshot
// after workspace tabs are hydrated.
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
@@ -1597,9 +1464,7 @@ export class AgentHookServer {
return
}
// Why: bound request time so a slow/stalled client cannot hold a socket
// open indefinitely (slowloris-style). The hook endpoints are local and
// should complete in well under a second.
// Why: bound request time so a stalled client can't hold a socket open (slowloris).
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
req.destroy()
})
@@ -1625,17 +1490,14 @@ export class AgentHookServer {
res.writeHead(204)
res.end()
} catch {
// Why: agent hooks must fail open. The receiver returns success for
// malformed payloads so a newer or broken hook never blocks the agent.
// Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
res.writeHead(204)
res.end()
}
})
await new Promise<void>((resolve, reject) => {
// Why: swap the startup error handler on success so a later runtime
// error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject
// an already-settled promise or crash the main process as unhandled.
// Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event.
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
reject(err)
@@ -1658,11 +1520,7 @@ export class AgentHookServer {
}
stop(): void {
// Why: flush any pending debounced write to disk BEFORE we clear the
// in-memory map. Quit-time state must be captured even if the trailing
// timer was scheduled but had not yet fired; otherwise a multi-agent
// run that ended its last hook event <250 ms before quit would lose
// that final delta on relaunch.
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
this.server?.close()
this.server = null
@@ -1675,9 +1533,7 @@ export class AgentHookServer {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
// Why: intentionally do NOT delete the endpoint file on stop(). A stale
// file points at a dead port, which matches the fail-open policy. Unlink
// would introduce a TOCTOU race vs. a concurrent Orca instance.
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
this.endpointDir = null
this.endpointFilePathCache = null
this.endpointFileWritten = false
@@ -1693,13 +1549,7 @@ export class AgentHookServer {
this.notifyStatusChangeListeners()
}
/** Why: invoked from the renderer-driven agentStatus:drop IPC when a user
* dismisses a still-active pane's status row. We must NOT wipe
* lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may
* still be alive, and the next hook event would otherwise arrive with an
* empty prompt and missing tool snapshot until a fresh UserPromptSubmit
* lands. clearPaneState (which wipes all three caches) is the right shape
* only for PTY-teardown. */
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
if (!this.deleteStatusEntry(paneKey)) {
return
@@ -1722,8 +1572,7 @@ export class AgentHookServer {
let statusChanged = false
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
const entry = rawEntry as EnrichedAgentHookEventPayload
// Why: legacy/unstamped rows cannot be safely attributed to one host.
// Leave them for normal pane teardown instead of risking cross-host loss.
// Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown.
if (entry.connectionId !== normalizedConnectionId) {
continue
}
@@ -1732,14 +1581,11 @@ export class AgentHookServer {
}
}
if (statusChanged) {
// Why: one disconnect can own many panes; persist and notify subscribers
// once so cleanup cost does not fan out with the pane count.
// Why: persist/notify once — one disconnect can own many panes.
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
// Why: another host can overwrite the same pane key in main's cache while
// renderer still shows this connection's older row. Always send the
// connection cutoff, even when no current main entry matched.
// Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row.
this.onPaneStatusCleared?.({
transient: true,
connectionId: normalizedConnectionId,
@@ -1837,10 +1683,7 @@ export class AgentHookServer {
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
// Why: only schedule a write when we actually evicted a status entry —
// dropping prompt/tool caches for a pane that never produced a hook
// event does not change the on-disk file, and skipping the write avoids
// re-stat'ing on every dead-pane teardown.
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
@@ -1876,9 +1719,7 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
// Why: managed hooks source this file at invocation time. Packaged builds
// use a stable file for restart handoff; dev callers pass a per-instance
// namespace so parallel `pnpm dev` runs do not steal each other's hooks.
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
@@ -1912,17 +1753,13 @@ export class AgentHookServer {
if (!this.lastStatusFilePath) {
return
}
// Why: defensive — keeps hydrate idempotent against repeated start()
// calls; production callers always have an empty map here, but a future
// re-start path must not silently merge prior-session state.
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
this.state.lastStatusByPaneKey.clear()
let raw: string
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
} catch (err) {
// Why: missing file is the common case (first launch).
// Other errors (EACCES, etc.) degrade to empty hydration with a single
// warn so the dashboard renders normally.
// Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[agent-hooks] failed to read last-status file:', err)
}
@@ -1956,10 +1793,7 @@ export class AgentHookServer {
let hydrated = 0
let dropped = 0
let prunedLegacyClaudeSubagents = 0
// Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so
// entries from worktrees archived weeks ago do not pile up forever. Use
// Date.now() once to keep the cutoff consistent across all entries this
// tick.
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
for (const [paneKey, rawEntry] of Object.entries(entries)) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
@@ -1977,16 +1811,14 @@ export class AgentHookServer {
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
if (entry.connectionId) {
// Why: a restarted process can observe an earlier wall clock; seed
// transport ordering so new events and clears stay after disk state.
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId)
this.connectionTimestampWatermarkById.set(
entry.connectionId,
Math.max(previousWatermark ?? -1, entry.receivedAt)
)
}
// Why: preserve only working children across restart. Live activity
// confirms them; a later complete inventory may reap stale seeds.
// Why: seed only working children across restart; a later full inventory reaps stale ones.
if (entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
@@ -2005,14 +1837,10 @@ export class AgentHookServer {
)
}
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
// Why: persist load-time pruning once so legacy idle rows do not consume
// parse/filter work again on every launch.
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
this.runStatusPersist()
} else if (hydrated > 0) {
// Why: prime from the raw on-disk bytes (not a re-serialization) so the
// dedup is robust against future shape drift in serializeStatusFile.
// Only prime when hydration was lossless — if entries were dropped
// during sanitize, the in-memory map diverges from the on-disk bytes.
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
this.lastWrittenJson = raw
}
}
@@ -2020,9 +1848,7 @@ export class AgentHookServer {
private serializeStatusFile(): string {
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
// Why: defensive — never persist invalid keys even if they slipped
// into the in-memory map somehow. Same invariant the hydrate path
// enforces.
// Why: never persist invalid keys (matches the hydrate-path invariant).
if (!isValidPaneKey(paneKey)) {
continue
}
@@ -2037,8 +1863,7 @@ export class AgentHookServer {
if (!this.lastStatusFilePath) {
return
}
// Why: each call resets the timer; the disk write fires
// STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst.
// Why: reset the timer each call so the write fires only after the last event in a burst.
if (this.statusPersistTimer) {
clearTimeout(this.statusPersistTimer)
}
@@ -2046,9 +1871,7 @@ export class AgentHookServer {
this.statusPersistTimer = null
this.runStatusPersist()
}, STATUS_PERSIST_DEBOUNCE_MS)
// Why: don't keep the event loop alive just for a status flush — quit
// already triggers flushStatusPersistSync(). On Node 12+ unref() is a
// no-op when called on an already-unref'd timer.
// Why: don't keep the event loop alive just for a status flush — quit already flushes sync.
if (typeof this.statusPersistTimer.unref === 'function') {
this.statusPersistTimer.unref()
}
@@ -2100,10 +1923,7 @@ export class AgentHookServer {
}
}
/** Test-only accessor for the per-instance listener state. The `_internals`
* shim needs to reach this without exposing `state` on the public surface
* to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
* so we expose a narrow getter rather than casting the private field. */
/** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */
_getStateForTests(): HookListenerState {
return this.state
}
@@ -2121,8 +1941,7 @@ export const agentHookServer = new AgentHookServer()
// Why: exported for test coverage of the per-agent field extractors.
export const _internals = {
// Why: bind the test-helper to the singleton's state so existing tests keep
// exercising the same caches the live server uses.
// Why: bind the test-helper to the singleton's state so tests exercise the live caches.
normalizeHookPayload: (
source: AgentHookSource,
body: unknown,
+28 -84
View File
@@ -14,26 +14,11 @@ import { app } from 'electron'
import { parseDaemonPidFile, startTimeMatches } from './daemon-health'
/**
* Relocates the terminal daemon's process image out of the app install
* directory into userData so it survives Windows auto-updates.
*
* Why: the daemon is forked as plain Node via ELECTRON_RUN_AS_NODE, so its
* image is the install-dir Orca.exe and its loaded modules (node-pty native,
* ConPTY runtime) map from the install dir. On update, electron-builder's NSIS
* installer deletes the old install and force-closes every process whose image
* lives under it — killing the daemon and every live terminal it owns. Copying
* the daemon's whole file closure to a version-keyed userData dir and forking
* from that copy takes its image + loaded modules out of the installer's reach.
*
* The copy keeps the ELECTRON binary run as node (not stock node.exe): a copy
* of Orca.exe (renamed to a distinct image name) is byte-identical, so
* run-as-node behavior — no console flashing, asar-correct — matches the in-dir
* fork exactly. The win-unpacked layout is mirrored verbatim so
* require('node-pty') and node-pty's native loader resolve the relocated tree
* identically to the packaged app.
*
* Fail-open everywhere: any failure returns null and the caller forks the
* install-dir host — the pre-relocation behavior, byte-identical off win32.
* Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it
* survives Windows auto-updates: the NSIS installer deletes the old install and force-kills every process
* imaged under it, which would otherwise kill the daemon and its live terminals. The relocated exe is a
* run-as-node Orca.exe copy (not node.exe) so there's no console flash and asar still resolves. Fail-open:
* any failure returns null and the caller forks the install-dir host (pre-relocation behavior).
*/
export type RelocatedDaemonHost = {
@@ -46,22 +31,13 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// The relocated host is machine-specific runtime (~260MB). It must live under
// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive
// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder
// name is shared verbatim with the NSIS uninstall cleanup
// (config/nsis/daemon-host-uninstall.nsh), which removes
// %LOCALAPPDATA%\<LOCAL_HOST_ROOT_NAME>\daemon-host — keep the two in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// The relocated host exe is a copy of Orca.exe renamed to a distinct image
// name. The NSIS updater's name-based kill (`taskkill /IM Orca.exe`) matches by
// image name, so a distinct name spares the daemon from that branch, while the
// userData path (outside $INSTDIR) spares it from the path-based branch.
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe'
// V8 snapshots + ICU data the Electron bootstrap reads even under
// ELECTRON_RUN_AS_NODE; siblings of Orca.exe in win-unpacked.
// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe.
const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']
type CopyOp = {
@@ -89,9 +65,7 @@ type MaterializeMarker = {
entryRelPath: string
}
// Uses win32 path semantics so Windows layout paths (drive letters, `\`)
// decompose correctly regardless of host OS — needed for cross-platform unit
// tests; production runs this on win32 only.
// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only.
function toPosixRelative(fromDir: string, absPath: string): string {
return winPath.relative(fromDir, absPath).split(winPath.sep).join('/')
}
@@ -100,8 +74,7 @@ function destPath(root: string, destRel: string): string {
return join(root, ...destRel.split('/'))
}
// Mirror getDaemonEntryPath()'s resolution order (unpacked root first, then
// out/main) so the copied entry is the exact file the in-dir fork would run.
// Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run.
function resolveEntrySourcePath(resourcesPath: string): string {
const unpackedRoot = join(resourcesPath, 'app.asar.unpacked')
const direct = join(unpackedRoot, 'daemon-entry.js')
@@ -111,8 +84,7 @@ function resolveEntrySourcePath(resourcesPath: string): string {
return join(unpackedRoot, 'out', 'main', 'daemon-entry.js')
}
// Discover the relocation inputs from the live packaged process, or null when
// relocation does not apply (non-win32, dev, or missing resourcesPath).
// Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath).
function collectDaemonHostSources(): DaemonHostSources | null {
if (process.platform !== 'win32' || !app.isPackaged) {
return null
@@ -133,12 +105,7 @@ function collectDaemonHostSources(): DaemonHostSources | null {
}
}
// node-pty ships debug symbols (.pdb) and a win32 prebuild dir per CPU arch; the
// run-as-node daemon loads neither the symbols nor any non-host-arch prebuild
// (verified against the live daemon's loaded module list), so they are filtered
// out of the copy — the bulk of node-pty's on-disk size. Keyed on the host arch
// rather than dropping arm64 outright so a future Windows-arm64 build keeps the
// `win32-arm64` prebuild it actually needs and prunes `win32-x64` instead.
// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs.
const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase()
function isRuntimeNodePtyPath(sourcePath: string): boolean {
const p = sourcePath.toLowerCase()
@@ -151,29 +118,20 @@ function isRuntimeNodePtyPath(sourcePath: string): boolean {
}
/**
* The ordered copy plan. Every destRel mirrors the source's win-unpacked
* relative path so require() and node-pty's native loader resolve the mirror
* identically to the packaged app. Pure over its inputs so tests can assert the
* layout without a real build.
* The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require()
* and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout.
*/
export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources
const ops: CopyOp[] = []
// Electron host binary + V8/ICU data blobs at the dest root. The exe is
// renamed to a distinct image name so the NSIS updater's name-based
// `taskkill /IM Orca.exe` can't match it; the blobs beside it are read by the
// Electron bootstrap by fixed name. Top-level DLLs are deliberately NOT copied
// — they are all GPU/graphics/media (swiftshader, vulkan, d3d, dxcompiler,
// ffmpeg) that a windowless run-as-node host never loads (verified empirically
// against the live daemon's module list), so copying them only wastes ~48MB.
// Host exe (renamed) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' })
for (const name of RUNTIME_DATA_FILES) {
ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true })
}
// Daemon bundle: entry + its sibling chunks/ + the unpacked out/package.json
// (CJS/ESM loader resolution), mirrored verbatim.
// Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim.
ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' })
const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks')
ops.push({
@@ -190,10 +148,7 @@ export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
optional: true
})
// node-pty package tree (native conpty.node + conpty/ runtime dir). It is a
// sibling of app.asar.unpacked; require('node-pty') resolves it by walking up
// from the mirrored daemon-entry dir to resources/node_modules. Filtered to
// drop .pdb debug symbols and other-arch prebuilds the host never loads.
// node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds.
const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty')
ops.push({
sourcePath: nodePtyDir,
@@ -245,9 +200,7 @@ function readMarker(dir: string): MaterializeMarker | null {
}
function hostRootDir(): string {
// Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME). Fall back to userData only
// if LOCALAPPDATA is somehow unset — a no-op off win32, where relocation never
// runs anyway; on win32 packaged the env var is always present.
// Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset.
const localAppData = process.env.LOCALAPPDATA
const base =
typeof localAppData === 'string' && localAppData.length > 0
@@ -257,9 +210,8 @@ function hostRootDir(): string {
}
/**
* Cheap idempotency check: the relocated host for the current version, or null.
* Valid only when the marker matches this version AND the exe + entry exist, so
* a partial or stale copy never reports ready.
* The relocated host for the current version, or null. Valid only when the marker matches this version
* AND the exe + entry exist, so a partial or stale copy never reports ready.
*/
export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
const sources = collectDaemonHostSources()
@@ -281,11 +233,8 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
}
/**
* Ensure the current version's daemon host is materialized under
* userData/daemon-host/<version>, returning its fork paths or null (fail-open).
* Idempotent: a valid marker for this version short-circuits without recopying.
* The copy stages into a temp sibling and is published by atomic rename, so a
* crash mid-copy never leaves a half-populated dest.
* Materialize the current version's daemon host, returning its fork paths or null (fail-open). Idempotent
* via marker; stages into a temp sibling and publishes by atomic rename, so a crash mid-copy never leaves a half-populated dest.
*/
export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
const existing = getRelocatedDaemonHost()
@@ -304,8 +253,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
mkdirSync(root, { recursive: true })
rmSync(staging, { recursive: true, force: true })
executeManifest(buildDaemonHostManifest(sources), staging)
// Marker written LAST: an interrupted copy leaves a marker-less staging dir
// that the next launch discards, never a dest the cheap check trusts.
// Marker written LAST so an interrupted copy leaves a marker-less staging dir the next launch discards.
const marker: MaterializeMarker = {
version,
completedAt: new Date().toISOString(),
@@ -336,10 +284,8 @@ function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean {
}
/**
* App versions still pinned by a live daemon, read from the daemon-v<N>.pid
* files under `runtimeDir`. A surviving daemon runs from its version's host dir,
* so its dir must never be reclaimed while the process is alive. On win32 the
* start-time check cannot verify, so a matching pid pins conservatively.
* App versions still pinned by a live daemon (from daemon-v<N>.pid files under `runtimeDir`), whose
* host dir must not be reclaimed while alive. On win32 start-time can't verify, so a matching pid pins conservatively.
*/
export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
const pinned = new Set<string>()
@@ -359,8 +305,7 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
} catch {
continue
}
// appVersion null => a pre-relocation daemon forked from the install dir,
// which pins no host dir here.
// appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here.
if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) {
pinned.add(parsed.appVersion)
}
@@ -369,9 +314,8 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
}
/**
* Reclaim daemon-host/<ver> dirs whose ver is neither the current version nor
* pinned by a live daemon. Best-effort — never throws; a still-locked or
* concurrently-staging dir is simply retried on a future launch.
* Reclaim daemon-host/<ver> dirs that are neither the current version nor pinned by a live daemon.
* Best-effort — never throws; a locked/staging dir is retried on a future launch.
*/
export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet<string>): void {
if (process.platform !== 'win32' || !app.isPackaged) {
+51 -174
View File
@@ -1,9 +1,4 @@
/* eslint-disable max-lines -- Why: this file covers the entire restart flow
of daemon-init — construction, the 7-step sequence from
docs/daemon-staleness-ux.md §Phase 1, and the concurrency coalescer. A
single describe block with shared mocks keeps setup in one place; splitting
across files would duplicate the vi.hoisted boundary mocks with no cleaner
ownership seam. */
/* eslint-disable max-lines -- Why: covers daemon-init's full restart flow (7-step sequence per docs/daemon-staleness-ux.md §Phase 1 + coalescer); one describe block keeps shared mocks in one place. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from './types'
@@ -15,11 +10,7 @@ const FAKE_APP_PATH = '/fake/app'
const FAKE_APP_OUT_MAIN_PATH = join(FAKE_APP_PATH, 'out', 'main')
const FAKE_DAEMON_ENTRY_PATH = join(FAKE_APP_OUT_MAIN_PATH, 'daemon-entry.js')
// Why: the restart flow touches many boundary modules (electron app paths, fs
// for dir creation, net for socket probe, DaemonClient over that socket, the
// spawner's launcher, ipc/pty listener binders). We only care here about the
// observable sequencing and identity invariants of runRestartDaemon, so every
// non-daemon-init dependency is replaced by a minimal stub that records calls.
// Why: we only care about runRestartDaemon's observable sequencing/identity invariants, so every non-daemon-init dependency is a minimal stub.
const {
getPathMock,
getAppPathMock,
@@ -61,21 +52,14 @@ const {
const probeSocketExistsMock = vi.fn((_path?: string) => false)
const writeFileSyncMock = vi.fn()
// Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive's
// catch treats every legacy pid file as unreadable — matching the pre-fix
// cleanup behavior every existing test was written against.
// Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive treats every legacy pid file as unreadable (pre-fix cleanup behavior).
const readFileSyncMock = vi.fn((): string => {
throw new Error('ENOENT')
})
const unlinkSyncMock = vi.fn()
const forkMock = vi.fn()
const netConnectMock = vi.fn(() => {
// Why: the real probeSocket() in daemon-init connects to the socket and
// resolves true on 'connect', false on 'error'. Our launcher never runs
// in these tests (healthCheckDaemon short-circuits), but probeSocket is
// also invoked by cleanupDaemonForProtocol — stub the socket object so
// the 'error' path fires synchronously and cleanupDaemonForProtocol's
// alive=false branch runs without side effects.
// Why: stub the socket so probeSocket's 'error' path fires and cleanupDaemonForProtocol's alive=false branch runs without side effects.
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
return {
on(event: string, cb: () => void) {
@@ -114,8 +98,7 @@ const {
}
})
// Why: every DaemonSpawner constructed under test pushes into this array so
// assertions can check "was the *same* spawner reused across restart?".
// Why: every DaemonSpawner pushes here so assertions can check the *same* spawner was reused across restart.
const spawnerInstances: MockSpawner[] = []
const ensureRunningOverrides: (() => Promise<{
socketPath: string
@@ -126,11 +109,9 @@ const {
const lifecycleLeaseErrors: Error[] = []
const disconnectOnlyErrors: Error[] = []
const routerSubscriptionError: { current: Error | null } = { current: null }
// Same for DaemonPtyAdapter. The test asserts the replacement adapter is a
// fresh instance whose respawn closure targets the *original* spawner.
// Same for DaemonPtyAdapter tests assert the replacement adapter is fresh but its respawn closure targets the *original* spawner.
const adapterInstances: MockAdapter[] = []
// Why: adapters are constructed inside initDaemonPtyProvider, so tests that
// need listSessions to report live sessions set this before calling init.
// Why: adapters are built inside initDaemonPtyProvider, so tests set this before init to make listSessions report live sessions.
const defaultListSessionsSessions: { sessionId: string }[] = []
const listProcessesControl: {
current: null | (() => Promise<{ sessionId: string }[]>)
@@ -231,10 +212,7 @@ type MockAdapter = {
disconnectOnly: ReturnType<typeof vi.fn>
onData: ReturnType<typeof vi.fn>
onExit: ReturnType<typeof vi.fn>
// Why: MockAdapter is fed through `new DaemonPtyRouter({ current, legacy })`
// during the "legacy preservation" test. The real router calls onData/onExit
// on each adapter; our stub returns a no-op unsubscribe so the router can
// subscribe without exploding.
// Why: the router calls onData/onExit on each adapter; the stub returns a no-op unsubscribe so router subscription doesn't explode.
callOrder: string[]
}
@@ -291,9 +269,7 @@ vi.mock('./daemon-spawner', () => ({
this.launcher = opts.launcher
this.socketCounter = 0
this.handle = null
// Why: each ensureRunning bumps a counter into the returned socketPath
// so the test can verify the *replacement* adapter is constructed with
// info from the second ensureRunning call, not stale info from the first.
// Why: each ensureRunning bumps a counter into socketPath so tests can tell the replacement adapter used the second call, not the first.
this.ensureRunning = vi.fn(async () => {
const override = ensureRunningOverrides.shift()
if (override) {
@@ -451,10 +427,7 @@ async function importFresh() {
unlinkOwnedDaemonPidFileMock.mockReturnValue(true)
getProcessStartedAtMsMock.mockReset()
getProcessStartedAtMsMock.mockReturnValue(1_000_000)
// Why: importing daemon-init *after* resetModules means the module-level
// `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is
// the only way to reliably exercise the "first-time init" path and the
// coalescer independently.
// Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer.
return import('./daemon-init')
}
@@ -552,8 +525,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => {
const mod = await importFresh()
// Why: live-pty-gate is intentionally unmocked — import from the same fresh
// module registry so the module-level gate state matches daemon-init's.
// Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's.
const gate = await import('../claude-accounts/live-pty-gate')
defaultListSessionsSessions.push({ sessionId: 'claude-alive' })
gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead'])
@@ -563,8 +535,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(gate.hasLiveClaudePtys()).toBe(true)
gate.markClaudePtyExited('claude-alive')
// Why: proves 'claude-dead' was released by the daemon reconcile — the
// surviving session was the only id still holding the gate.
// Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone.
expect(gate.hasLiveClaudePtys()).toBe(false)
} finally {
gate.markClaudePtyExited('claude-alive')
@@ -714,11 +685,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
// Why: seed the original adapter with active sessions so
// fanoutSyntheticExits has something to do. The real adapter snapshots
// activeSessionIds then clears it; the mock emulates that so a regression
// that measures killedCount *after* fanout (when the set is empty) would
// surface as `killedCount === 0` here.
// Why: seed active sessions and mock the snapshot-then-clear so a regression measuring killedCount *after* fanout surfaces as 0.
const originalAdapter = adapterInstances[0]
let activeIds = ['sess-a', 'sess-b', 'sess-c']
originalAdapter.getActiveSessionIds.mockImplementation(() => [...activeIds])
@@ -734,13 +701,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const result = await mod.restartDaemon()
// killedCount must be 3 — proves the count was taken *before* the fanout
// cleared the set. A bug that swapped these two lines in source would
// report 0 here.
// killedCount must be 3 — proves the count was taken *before* fanout cleared the set (a swapped-order bug reports 0).
expect(result.killedCount).toBe(3)
expect(originalAdapter.fanoutSyntheticExits).toHaveBeenCalledWith(-1)
// The load-bearing ordering invariant: the synthetic exits must reach
// the renderer *before* listeners are torn down. Step 1 before Step 2.
// Ordering invariant: synthetic exits must reach the renderer *before* listeners are torn down (Step 1 before 2).
expect(order).toEqual(['fanout', 'unbind'])
})
@@ -835,9 +799,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(replacementAdapter.options.socketPath).toBe('/fake/socket-2')
expect(replacementAdapter.options.tokenPath).toBe('/fake/token-2')
// Invoking the replacement adapter's respawn closure must drive the
// *same* original spawner (matches the crash-respawn closure baked into
// the first adapter — see daemon-init.ts step 5 comment).
// The replacement adapter's respawn closure must drive the *same* original spawner (see daemon-init.ts step 5).
originalSpawner.resetHandle.mockClear()
originalSpawner.ensureRunning.mockClear()
await replacementAdapter.options.respawn?.()
@@ -865,8 +827,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(setLocalPtyProviderMock.mock.calls[1][0]).toBe(replacementAdapter)
expect(mod.getDaemonProvider()).toBe(replacementAdapter)
// Step 7: rebind must run *after* Step 6. The last rebind call index
// must be greater than the last setLocalPtyProvider call index.
// Step 7: rebind must run *after* Step 6 (the provider swap).
const rebindOrder = rebindLocalProviderListenersMock.mock.invocationCallOrder.at(-1) ?? -1
const swapOrder = setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) ?? -1
expect(rebindOrder).toBeGreaterThan(swapOrder)
@@ -875,19 +836,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
it('preserves legacy adapter instances by identity, drains outgoing router via disposeRouterOnly, and re-discovers legacy sessions on the new router', async () => {
const mod = await importFresh()
// Why: initDaemonPtyProvider only constructs legacy adapters when
// probeSocket returns true for a legacy socket path. The real flow runs
// createLegacyDaemonAdapters which calls probeSocket per previous version.
// Simplest seam for this test: directly construct a router with a legacy
// adapter and install it via replaceDaemonProvider, bypassing
// createLegacyDaemonAdapters' socket-probe machinery.
// Why: bypass createLegacyDaemonAdapters' socket-probe machinery — directly construct a router with a legacy adapter and install it.
await mod.initDaemonPtyProvider()
const { DaemonPtyRouter } = await import('./daemon-pty-router')
const { DaemonPtyAdapter } = await import('./daemon-pty-adapter')
const currentAtConstruction = adapterInstances[0]
// Construct a legacy adapter using the mocked constructor (pushes into
// adapterInstances) — index 1.
const legacyAdapter = new DaemonPtyAdapter({
socketPath: '/fake/legacy.sock',
tokenPath: '/fake/legacy.token',
@@ -897,9 +851,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
current: currentAtConstruction as unknown as InstanceType<typeof DaemonPtyAdapter>,
legacy: [legacyAdapter as unknown as InstanceType<typeof DaemonPtyAdapter>]
})
// Why: spy on the *outgoing* router's disposeRouterOnly so we can prove it
// was invoked (not just that legacy adapters survived — a no-op
// disposeRouterOnly would leak listeners but still leave adapters alive).
// Why: spy on the outgoing router's disposeRouterOnly — adapter survival alone wouldn't catch a no-op that leaks listeners.
const disposeRouterOnlySpy = vi.spyOn(routerWithLegacy, 'disposeRouterOnly')
const oldRouterDispose = vi.spyOn(routerWithLegacy, 'dispose')
mod.replaceDaemonProvider(routerWithLegacy)
@@ -911,22 +863,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const newRouter = provider as InstanceType<typeof DaemonPtyRouter>
expect(newRouter).not.toBe(routerWithLegacy)
// Legacy adapter instance is preserved by identity — not reconstructed,
// not defensively copied, not disposed.
// Legacy adapter is preserved by identity — not reconstructed, copied, or disposed.
const legacies = newRouter.getLegacyAdapters()
expect(legacies).toHaveLength(1)
expect(legacies[0]).toBe(legacyAdapter)
expect(legacyAdapter.dispose).not.toHaveBeenCalled()
// The outgoing router was drained via disposeRouterOnly (router-only
// teardown), so legacy adapters' underlying connections are untouched.
// Router drained via disposeRouterOnly (router-only teardown), so legacy adapters' connections are untouched.
expect(legacyAdapter.disconnectOnly).not.toHaveBeenCalled()
// The outgoing router's subscriptions were drained but the adapters
// behind it were NOT disposed — that's the whole point of disposeRouterOnly.
// disposeRouterOnly drained subscriptions but did NOT dispose the adapters behind it.
expect(disposeRouterOnlySpy).toHaveBeenCalledTimes(1)
expect(oldRouterDispose).not.toHaveBeenCalled()
// The replacement router must re-run discovery so spawns targeting a
// surviving legacy sessionId still route to the legacy adapter.
// The replacement router re-runs discovery so spawns for a surviving legacy sessionId still route to the legacy adapter.
expect(legacyAdapter.listProcesses).toHaveBeenCalled()
})
@@ -962,8 +910,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
// initDaemonPtyProvider sets up a bare adapter when createLegacyDaemonAdapters
// finds nothing — confirm that shape persists across restart.
// initDaemonPtyProvider yields a bare adapter when no legacy adapters exist — confirm that shape persists across restart.
const { DaemonPtyAdapter } = await import('./daemon-pty-adapter')
const { DaemonPtyRouter } = await import('./daemon-pty-router')
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter)
@@ -981,15 +928,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const originalSpawner = spawnerInstances[0]
const originalAdapter = adapterInstances[0]
// Build an ordered trace by stamping each step as it fires. Cleanup is
// the tricky one — it's the `cleanupDaemonForProtocol` call, observable
// via the probeSocket→healthCheckDaemon→killStaleDaemon branch. We use
// healthCheckDaemon as the cleanup-start marker because it's the first
// call inside createOutOfProcessLauncher → and also fires inside
// cleanupDaemonForProtocol's alive branch… actually in the default
// (probeSocket=false) path cleanup is a no-op beyond pid-unlink. The
// load-bearing observable is `resetHandle` — it fires *after* cleanup
// returns. So we instrument the spawner instead.
// Build an ordered trace by stamping each step; cleanup has no observable in the default probeSocket=false path, so instrument resetHandle instead.
const trace: string[] = []
originalAdapter.fanoutSyntheticExits.mockImplementation(() => trace.push('fanout'))
unbindLocalProviderListenersMock.mockImplementation(() => trace.push('unbind'))
@@ -1008,11 +947,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
await mod.restartDaemon()
void originalEnsureRunning // keep ref so tslint doesn't complain
// The full 7-step sequence in order. Step 3 (cleanupDaemonForProtocol)
// has no unique observable in the dead-socket branch, so it's implicitly
// ordered by the fact that resetHandle runs after unbind; if cleanup
// ever moved *after* resetHandle, we'd see `resetHandle` precede its
// expected position.
// Full 7-step order; Step 3 (cleanup) has no observable in the dead-socket branch, so it's pinned implicitly by resetHandle running after unbind.
expect(trace).toEqual([
'fanout',
'unbind',
@@ -1022,10 +957,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'rebind'
])
// A fresh adapter must have been constructed *between* ensureRunning
// and replaceProvider (Step 5 before Step 6). adapterInstances[1] is
// the replacement — its socketPath comes from the Step-4 ensureRunning
// result, so its existence proves the ordering.
// A fresh adapter built between ensureRunning and replaceProvider (Step 5 before 6); its Step-4 socketPath proves the ordering.
expect(adapterInstances).toHaveLength(2)
expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2')
expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce()
@@ -1035,12 +967,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => {
// Why: the default mock has probeSocket returning false, so Step 3's
// DaemonClient-based shutdown path is normally skipped. This test flips
// the socket to "alive" so cleanupDaemonForProtocol takes the
// client.ensureConnected → listSessions → shutdown RPC branch. Without
// this, the design doc's Risks section ("verify under both 'shutdown
// RPC succeeded' and 'fell back to killStaleDaemon' paths") is uncovered.
// Why: default probeSocket=false skips Step 3's shutdown RPC; flip the socket "alive" to cover the shutdown-RPC-succeeded branch.
const requestMock = vi.fn(async (method: string) => {
if (method === 'listSessions') {
@@ -1061,8 +988,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
}
})
// Make probeSocket return true for the current-version path by toggling
// both the fs.existsSync proxy AND net.connect resolving "alive".
// Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive".
probeSocketExistsMock.mockReturnValue(true)
netConnectMock.mockImplementationOnce(() => {
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
@@ -1139,13 +1065,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const originalSpawner = spawnerInstances[0]
// Why: without an explicit gate, `Promise.all([restartDaemon(),
// restartDaemon()])` evaluates arguments left-to-right *synchronously* —
// the first call's promise could resolve before the second invocation
// begins if all internal awaits resolved in a single microtask, which
// would leave the coalescer untested. The deferred gate holds the first
// restart inside `ensureRunning` until we release it, guaranteeing the
// second call enters while the first is genuinely mid-flight.
// Why: the deferred gate holds the first restart inside ensureRunning so the second call provably enters while the first is mid-flight.
let markEnsureRunningEntered: (() => void) | undefined
const ensureRunningEntered = new Promise<void>((resolve) => {
markEnsureRunningEntered = resolve
@@ -1164,33 +1084,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
await ensureRunningEntered
const call2 = mod.restartDaemon()
// Why: `async function restartDaemon` wraps each return in a fresh
// Promise, so `call1 === call2` does *not* hold even when the coalescer
// is working. The load-bearing proof is behavioral: while call1 is
// blocked on the ensureRunning barrier, call2 must NOT fork a parallel
// run of the 7-step sequence. If the coalescer fails, a second
// `runRestartDaemon` starts, which means a second `resetHandle` fires
// before we release the barrier. Check that counter at this exact
// moment — a non-coalescing implementation would already be at 2.
// Why: restartDaemon wraps each return in a fresh Promise, so call1===call2 can't prove coalescing; instead assert resetHandle stayed at 1 mid-flight.
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
expect(adapterInstances).toHaveLength(1)
releaseEnsureRunning?.()
const [r1, r2] = await Promise.all([call1, call2])
// Both resolved values must be structurally identical (same result
// object bubbled up through the shared runRestartDaemon promise).
// Both resolved values are structurally identical same result bubbled up through the shared runRestartDaemon promise.
expect(r1).toEqual(r2)
// resetHandle fires once per restart; ensureRunning fires once during
// init + once during restart. A second, un-coalesced restart would push
// these counters to 2 and 3 respectively.
// resetHandle=1/restart, ensureRunning=1 init+1 restart; an un-coalesced second restart would push these to 2 and 3.
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2)
expect(adapterInstances).toHaveLength(2)
// After the in-flight promise settles, a fresh restart is allowed to run
// — proves `.finally(() => restartInFlight = null)` actually cleared the
// slot. A stale restartInFlight would skip the work entirely.
// After the in-flight promise settles, a fresh restart runs — proves .finally cleared restartInFlight (a stale slot would skip work).
await mod.restartDaemon()
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(2)
expect(adapterInstances).toHaveLength(3)
@@ -2077,8 +1985,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'exit') {
// Why: deliver the stderr tail before the exit so the failure path
// sees the captured crash reason, mirroring a module-load crash.
// Why: deliver the stderr tail before exit so the failure path sees the crash reason (mirrors a module-load crash).
queueMicrotask(() => {
for (const dataCb of stderrDataCbs.slice()) {
dataCb(Buffer.from("Error: Cannot find module 'electron'\n"))
@@ -2103,8 +2010,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toMatch(/Cannot find module 'electron'/)
expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/)
// Why: the piped stderr must be released so the detached daemon does not
// keep the parent event loop alive after the failure.
// Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure.
expect(stderrDestroy).toHaveBeenCalled()
})
@@ -2277,8 +2183,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(forkMock).toHaveBeenCalled()
})
// Why: a net.connect stub whose 'connect' fires — makes probeSocket() report
// the wedged daemon's pipe as alive on every grace re-check.
// Why: net.connect stub whose 'connect' fires, so probeSocket() reports the pipe alive on every grace re-check.
function stubAliveSocketConnect() {
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
return {
@@ -2298,15 +2203,11 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
}
it('adopts a transiently wedged daemon that drains and reports live sessions within the grace window', async () => {
// Why: the Windows update-relaunch case — post-install disk/AV load wedges
// the daemon past the first hello budget, but it drains within seconds and
// still owns live sessions. The launcher must give it a bounded grace and
// ADOPT it rather than killing its live sessions.
// Why: Windows update-relaunch — post-install load wedges the daemon briefly; it still owns live sessions, so grace-adopt not kill.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
// First probe times out (still draining); the retry within the grace
// window succeeds and reports a live session.
// First probe times out (still draining); the retry within grace succeeds with a live session.
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {
@@ -2339,11 +2240,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('replaces a permanently wedged daemon after the grace window is exhausted (#8689)', async () => {
// Why: a daemon whose socket keeps accepting connections but whose event
// loop never answers hello would, under the old code, be preserved forever
// — every terminal spawn then failed with "Hello response timed out" with
// no recovery. After the bounded grace it must be replaced so the app gets
// working terminals again.
// Why: a socket that accepts connections but never answers hello was preserved forever (#8689); after grace it must be replaced.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -2354,8 +2251,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
disconnect: vi.fn()
}
}
// Every probe across the whole grace window times out (permanent wedge),
// then the freshly spawned daemon accepts the temporary adoption lease.
// Permanent wedge: every probe times out, then the freshly spawned daemon accepts the temporary adoption lease.
let daemonClientConstructionCount = 0
daemonClientMock.mockImplementation(function MockDaemonClient() {
daemonClientConstructionCount++
@@ -2404,30 +2300,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'/fake/token'
)
expect(forkMock).toHaveBeenCalled()
// The launcher probes the full grace budget before giving up: 1 initial
// probe + WEDGED_DAEMON_GRACE_RETRIES retries.
// The launcher probes the full grace budget: 1 initial probe + WEDGED_DAEMON_GRACE_RETRIES retries.
expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES)
} finally {
// Restore the answering default so the persistent throwing impl above
// does not leak into later tests (clearAllMocks clears calls, not impls).
// Restore the answering default: clearAllMocks clears calls not impls, so the throwing impl would leak into later tests.
daemonClientMock.mockImplementation(answeringDefault)
}
})
it('grace budget is generous enough to ride out a ~60s transient wedge', () => {
// Why: pins the magnitude. Each probe waits out the client's 5s hello
// timeout, so 1 + 11 probes ≈ 60s of drain grace. Shrinking this narrows
// the window in which a transiently wedged daemon's live sessions are
// preserved instead of replaced — don't cut it without field telemetry.
// Why: each probe waits the client's 5s hello timeout, so 1 + 11 probes ≈ 60s of drain grace; don't cut without telemetry.
expect(WEDGED_DAEMON_GRACE_RETRIES).toBeGreaterThanOrEqual(11)
})
it('preserves a daemon that stays wedged until the LAST allowed grace retry', async () => {
// Why: exercises the full grace loop end-to-end. The daemon throws on every
// probe except the final allowed one (1 + WEDGED_DAEMON_GRACE_RETRIES), on
// which it drains and reports a live session — so it must be preserved, not
// replaced. Cutting the retry budget below the drain point would replace a
// still-live daemon, which this test catches.
// Why: daemon drains only on the last allowed probe (1 + WEDGED_DAEMON_GRACE_RETRIES) — must be preserved, not replaced.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -2474,9 +2361,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => {
// Why: 'rejected' means the daemon answered and refused the handshake —
// it can never be adopted, so keeping it alive would strand the app with
// no terminals forever. Replacement stays the only recovery.
// Why: 'rejected' = daemon refused the handshake; it can never be adopted, so replacement is the only recovery.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -2521,17 +2406,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'/fake/token'
)
expect(forkMock).toHaveBeenCalled()
// Pins the 'rejected' fast-path: a daemon that actively refuses the
// handshake is never worth a grace window, so it is probed exactly once
// (no retries) before replacement. The other clients are the initial
// adoption attempt and the fresh daemon's temporary adoption lease.
// 'rejected' gets no grace window (probed once): count = initial adoption + rejected probe + fresh daemon lease.
expect(daemonClientMock).toHaveBeenCalledTimes(3)
})
it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => {
// Why: the regression contract — a pid file with startedAtMs null (all
// pre-fix Windows pid files) resolves launch identity to 'unknown'. With
// a live daemon answering on the pipe, that must ADOPT, never replace.
// Why: startedAtMs null (all pre-fix Windows pid files) → identity 'unknown'; a live daemon must ADOPT, not replace.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -2549,8 +2429,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => {
// Why: getProcessStartedAtMs has no cheap Windows implementation, so the
// pid file's pid-recycling guard depends on the ready-message fallback.
// Why: getProcessStartedAtMs has no cheap Windows impl, so the pid-recycling guard uses the ready-message fallback.
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -2590,8 +2469,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => {
// Why: deleting a live-but-wedged legacy daemon's token file makes its
// sessions permanently unadoptable — no future launch could authenticate.
// Why: deleting a live legacy daemon's token file makes its sessions permanently unadoptable.
const mod = await importFresh()
readFileSyncMock.mockReturnValue('{"pid":123}')
// process.pid is guaranteed alive, so the liveness probe succeeds.
@@ -2608,8 +2486,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => {
const mod = await importFresh()
readFileSyncMock.mockReturnValue('{"pid":123}')
// Why: spy on process.kill so the liveness probe deterministically reports
// "no such process" without depending on an unallocated real pid.
// Why: spy process.kill to force a deterministic ESRCH instead of relying on an unallocated real pid.
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('ESRCH')
})
+67 -230
View File
@@ -1,11 +1,5 @@
/* eslint-disable max-lines -- Why: this module owns the complete daemon
lifecycle for the Electron main process — init, out-of-process launch,
current+legacy adapter wiring, restart orchestration (the 7-step sequence
from docs/daemon-staleness-ux.md §Phase 1), and teardown on app quit. Splitting
it would scatter the "swap the running provider atomically" invariant across
files with no cleaner ownership seam: restart, replaceDaemonProvider, and the
module-level spawner/adapter singletons must stay co-located so a future
change cannot leave them drifting out of sync. */
/* eslint-disable max-lines -- Why: owns the full daemon lifecycle (init, launch, adapter wiring,
restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { app } from 'electron'
@@ -58,31 +52,14 @@ import {
hasSeededUnconfirmedClaudePtys
} from '../claude-accounts/live-pty-gate'
// Why: daemon init runs concurrently with window load, so harness-side stderr
// arrival times are useless — in-process `t` lets the startup benchmark derive
// how long the daemon cold-start path actually took.
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
// Why: how many extra hello+listSessions probes to make against a wedged-but-
// connectable daemon before replacing it. Each probe waits out the client's 5s
// hello timeout, so this spaces re-checks ~5s apart: 1 initial + 11 retries ≈
// 60s of grace for a transiently wedged daemon (Windows update-relaunch drain)
// to answer and be preserved WITH its live sessions, before a permanent wedge
// (#8689) is replaced. Deliberately generous to keep live-session loss on the
// transient path as close to zero as possible.
//
// A transient wedge drains early (well under the 60s local-PTY fail-open cap),
// so its startup is short. Only a *permanent* wedge runs the full window; it can
// then approach/exceed the fail-open cap, at which point restored panes fail
// open to the in-process provider for the session and adopt the freshly forked
// daemon on the next launch — a rare path that still recovers, versus the old
// forever-broken behavior. Trade-off: a transient wedge owning live sessions
// that takes longer than ~60s to drain is replaced (live processes lost, though
// scrollback cold-restores). Raise this only alongside the fail-open cap.
// Why: extra hello+listSessions probes (~5s each) giving a wedged-but-connectable daemon ~60s grace to answer and keep its live sessions before a permanent wedge (#8689) is replaced; raise only alongside the fail-open cap.
export const WEDGED_DAEMON_GRACE_RETRIES = 11
const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000
const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
@@ -92,10 +69,7 @@ let spawner: DaemonSpawner | null = null
type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
let adapter: DaemonProvider | null = null
// Why: coalesce concurrent restartDaemon() calls so two clicks (or a UI
// click racing an internal caller) can't both enter the 7-step sequence —
// the second entry would read the already-disposed current adapter and
// race cleanupDaemonForProtocol against a half-spawned replacement.
// Why: coalesce concurrent restartDaemon() calls so two entries can't race the 7-step sequence against a half-spawned replacement.
let restartInFlight: Promise<RestartDaemonResult> | null = null
function getRuntimeDir(): string {
@@ -112,9 +86,7 @@ function getHistoryDir(): string {
function getDaemonEntryPath(): string {
const appPath = app.getAppPath()
// Why: electron-builder unpacks daemon-entry.js so child_process.fork() can
// execute it from disk. In packaged apps app.getAppPath() points at
// app.asar, so redirect to the unpacked sibling before joining the script.
// Why: packaged app.getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
const directEntryPath = join(basePath, 'daemon-entry.js')
if (existsSync(directEntryPath)) {
@@ -123,10 +95,7 @@ function getDaemonEntryPath(): string {
return join(basePath, 'out', 'main', 'daemon-entry.js')
}
// Why: the detached daemon writes lifecycle events to a rotated file so field
// failures are diagnosable from a bundle. Honor the same hard privacy switch
// the local trace sink honors (ORCA_DIAGNOSTICS_DISABLED); absence of the arg
// is fully supported, so gating it off is safe and adoption-neutral.
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
function daemonLogArgs(): string[] {
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
if (disabled === '1' || disabled === 'true') {
@@ -135,9 +104,7 @@ function daemonLogArgs(): string[] {
return ['--log-file', getDaemonLogFilePath()]
}
// Why: before spawning a new daemon, check if an existing one is alive by
// attempting a TCP connection to the socket. If it connects, the daemon
// survived from a previous app session — reuse it instead of spawning.
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
function probeSocket(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
@@ -253,8 +220,7 @@ async function cleanupFailedDaemonAdoption(
...legacy.map((entry) => entry.disconnectOnly()),
(async () => {
try {
// Why: endpoint publication allows other authenticated clients to win;
// only daemon-side shutdownIfIdle may prove a failed adoption is killable.
// Why: other authenticated clients may win, so only daemon-side shutdownIfIdle can prove a failed adoption is killable.
await current.disconnectOnly()
} catch (error) {
current.dispose()
@@ -362,8 +328,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
const launchNonce = suppliedLaunchNonce ?? randomUUID()
let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath })
try {
// Why: acquire the full pair before any control-only probes so an expired
// inherited deadline cannot fire in the probe-to-adoption gap.
// Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap.
await adoptionClient.ensureConnected()
} catch {
adoptionClient.disconnect()
@@ -398,10 +363,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
// Why: a protocol-healthy daemon can outlive its launching app bundle (dev worktree rebuild, or packaged update replacing the app path).
const identity = await getDaemonLaunchIdentity(
runtimeDir,
socketPath,
@@ -417,8 +379,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
app.getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
// Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
@@ -434,28 +395,14 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
// Why: healthy daemon from a previous session answered a protocol ping — safe to reuse.
return preserveDaemon()
}
}
} else {
// Why: a busy machine (e.g. right after an update) can time out the
// health check while the daemon is alive and owning terminals. Killing
// it would destroy every live session, so re-verify with a session list
// first.
// Why: a busy machine can time out the health check on a live daemon; re-verify with a session list before killing its sessions.
let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
// Why: on a Windows update relaunch the daemon can be transiently wedged
// past every RPC budget (final checkpoint flush + installer/AV disk
// pressure) while its sessions are still alive — replacing it here is what
// killed those sessions. A pipe that still accepts connections proves a
// live daemon, so give a wedged-but-connectable daemon a bounded grace to
// drain and answer before deciding. A PERMANENTLY wedged daemon (accepts
// connections but its event loop never answers hello — #8689) exhausts the
// grace and falls through to replacement below, instead of being preserved
// forever, which strands the app with zero working terminals. 'rejected'
// means the daemon answered and refused the handshake — it can never be
// adopted, so it skips the grace and replacement stays the only recovery.
// Why: a wedged-but-connectable daemon (Windows update relaunch) may still own live sessions, so grace-retry before replacing; a permanent wedge (#8689) exhausts the grace, and 'rejected' skips it (handshake refused = never adoptable).
let graceRetry = 0
while (
liveSessionCount === null &&
@@ -480,18 +427,13 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
}
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
// before respawn so the new daemon does not race the stale process.
// Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one.
adoptionClient?.disconnect()
adoptionClient = null
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
const userDataPath = app.getPath('userData')
// Why: on win32 packaged, fork from a copy of the Electron runtime staged
// in userData so the daemon's image + loaded modules escape the install dir
// the NSIS updater deletes and force-closes. Staged here (not at app start)
// so the one-time copy stays off the first-paint path and is skipped on
// launches that adopt a live daemon. Fail-open: null → in-dir host, below.
// Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host.
const relocatedHost = materializeRelocatedDaemonHost()
// Fork the relocated entry when available; otherwise the install-dir entry.
const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath
@@ -509,37 +451,24 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
...daemonLogArgs()
],
{
// Why: detached daemons can outlive dev worktrees. Starting from
// userData keeps process.cwd() valid after a repo/worktree is deleted.
// Why: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted.
cwd: userDataPath,
// Why: detached + unref lets the daemon outlive the Electron process.
// stdout stays 'ignore' so the child never holds the parent's stdout
// open (which would block Electron exit); stderr is 'pipe' so a
// module-load crash during startup is captured instead of discarded
// (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1"
// because stderr was thrown away). The pipe is destroyed on readiness.
// Why: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1.
detached: true,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
// Why: run the relocated Orca.exe copy instead of the install-dir one.
// It is byte-identical, so run-as-node behavior is unchanged; only the
// image path moves out of the updater's kill zone.
// Why: run the byte-identical relocated Orca.exe so the image path sits outside the updater's kill zone.
...(relocatedHost ? { execPath: relocatedHost.execPath } : {}),
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
// Node.js process instead of an Electron renderer/main process. Without
// it, Electron's GPU/display initialization can interfere with native
// module operations like node-pty's posix_spawn of the spawn-helper.
// Why: run the fork as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper.
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
// Why: the detached daemon is plain Node and cannot call Electron's
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
// Why: the detached plain-Node daemon can't call app.getPath(), but shell rcfiles must live outside swept tmp.
ORCA_USER_DATA_PATH: userDataPath
}
}
)
// Why: keep only the startup-window stderr tail so a crash cause is
// visible without unbounded memory if the daemon spews before dying.
// Why: keep only the startup-window stderr tail so a crash cause is visible without unbounded memory.
const STARTUP_STDERR_MAX_BYTES = 8192
let startupStderr = ''
let collectingStderr = true
@@ -553,9 +482,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
}
child.stderr?.on('data', onStartupStderr)
// Why: once the daemon is up (or has failed) the parent must not keep a
// live handle on the detached daemon's stderr — a piped stream would ref
// the parent event loop and prevent Electron from exiting cleanly.
// Why: release the detached daemon's stderr once up/failed — a live piped stream refs the parent loop and blocks Electron exit.
const releaseStderr = (): void => {
collectingStderr = false
child.stderr?.off('data', onStartupStderr)
@@ -580,9 +507,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
}
settled = true
cleanupStartupListeners()
// Why: stderr was previously discarded, so a startup crash surfaced only
// as "exited with code 1". Attach the captured tail to the thrown error
// (which the fallback path reports) and log it so the real cause shows.
// Why: attach the captured stderr tail to the thrown error and log it so a startup crash isn't just "exited with code 1".
const stderrTail = startupStderr.trim()
if (stderrTail) {
console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`)
@@ -621,8 +546,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return
}
try {
// Why: hello and the PID record must share the daemon's self time
// and nonce so cleanup can identify this exact process incarnation.
// Why: pid record shares the daemon's self time and nonce so cleanup can identify this exact process incarnation.
writeFileSync(
pidPath,
serializeDaemonPidFile({
@@ -639,12 +563,9 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return
}
settled = true
// Why: the daemon process is detached after readiness; leaving
// startup listeners attached retains this launch promise closure.
// Why: daemon is detached after readiness; detach startup listeners so the launch promise closure isn't retained.
cleanupStartupListeners()
// Why: disconnect IPC channel, release the stderr pipe, and unref so
// Electron can exit without waiting for the daemon. The daemon keeps
// running detached.
// Why: release IPC/stderr and unref so Electron can exit without waiting; the daemon keeps running detached.
releaseStderr()
child.disconnect()
child.unref()
@@ -678,8 +599,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
tokenPath
)
} catch (error) {
// Why: another client may have adopted this still-live process. Keep its
// valid identity until exit, but remove a record published after an early exit.
// Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit.
let pidRecordRemoved = false
const removeExitedPidRecord = (): void => {
if (pidRecordRemoved) {
@@ -707,10 +627,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
logDaemonMilestone('daemon-init-start')
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init
// that deterministically outlasts the first-window timeout. Real triggers
// (stale-daemon cleanup, legacy probes on a busy disk) are not controllable
// from a test.
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs))
@@ -722,20 +639,14 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
launcher: createOutOfProcessLauncher(runtimeDir)
})
// Why: assign spawner/adapter only after both succeed. If ensureRunning()
// throws, a stale spawner would prevent shutdownDaemon() from cleaning up
// correctly on retry.
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
const info = await newSpawner.ensureRunning()
// Reclaim superseded daemon-host copies on EVERY launch, not just on a fresh
// spawn: surviving daemons make spawns rare, so a spawn-only sweep would let
// old-version copies accumulate. Current + live-daemon-pinned versions stay.
// Why: reclaim superseded daemon-host copies on EVERY launch (spawns are rare), keeping current + live-daemon-pinned versions.
pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir))
const launchMode = newSpawner.getHandle()?.mode
logDaemonMilestone('daemon-current-ready')
if (signal?.aborted) {
// Why: startup fail-open may already have allowed fallback LocalPtyProvider
// PTYs to spawn. Do not install late, but give an empty daemon one bounded
// authenticated retirement attempt; live adopted sessions reject it and survive.
// Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive).
const abortedStartupAdapter = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath
@@ -749,10 +660,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
socketPath: info.socketPath,
tokenPath: info.tokenPath,
historyPath: getHistoryDir(),
// Why: when the daemon process dies (e.g. killed by a signal, OOM, or
// cascading from a force-quit of child processes), the adapter's
// ensureConnected() detects the dead socket and calls this to fork a
// replacement daemon before retrying the connection.
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
respawn: async () => {
console.warn('[daemon] Daemon process died — respawning')
newSpawner.resetHandle()
@@ -763,8 +671,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
let legacyAdapters: DaemonPtyAdapter[] = []
let routedAdapter: DaemonProvider = newAdapter
try {
// Why: the launcher's temporary pair closes only after this permanent
// adapter pair is established, leaving no retirement gap during adoption.
// Why: the launcher's temporary pair closes only after this permanent pair is established, leaving no adoption gap.
await newAdapter.establishLifecycleLease()
releaseDaemonAdoptionLease(newSpawner.getHandle())
@@ -783,17 +690,13 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
})
: newAdapter
if (routedAdapter instanceof DegradedDaemonPtyProvider) {
// Why: the preserved daemon cannot create fresh terminals, but its live
// sessions may still be writable. Discover those ids so only known old
// sessions route to the degraded daemon; fresh panes fall back locally.
// Why: preserved daemon can't create fresh terminals; discover its live session ids so only they route to it (fresh panes fall back locally).
await routedAdapter.discoverDaemonSessions()
} else if (routedAdapter instanceof DaemonPtyRouter) {
await routedAdapter.discoverLegacySessions()
}
if (signal?.aborted) {
// Why: same late-swap guard after legacy discovery, which can also exceed
// the first-window startup timeout on slow or stale daemon state. Release
// every uninstalled adapter lease without killing its live sessions.
// Why: same late-swap guard after legacy discovery; release uninstalled adapter leases without killing live sessions.
await routedAdapter.disconnectOnly()
return
}
@@ -808,19 +711,13 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
spawner = newSpawner
adapter = routedAdapter
setLocalPtyProvider(routedAdapter)
// Why: desktop startup now lets the first window register PTY listeners
// before daemon init finishes. Rebind here so daemon PTYs still fan out
// data/exit events through the renderer and runtime listeners.
// Why: the first window may register PTY listeners before daemon init finishes; rebind so daemon PTYs still fan out events.
rebindLocalProviderListeners()
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
await reconcileSeededClaudeLivePtys(routedAdapter)
}
// Why: the Claude live-PTY gate is seeded pessimistically from persistence at
// store load. Once the daemon is up we know which of those sessions actually
// survived — release dead ids so they cannot defer OAuth refresh forever.
// Listing failures keep the seeds: over-holding the gate only delays a usage
// refresh, while releasing it early can rotate a live CLI's refresh token.
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
return
@@ -841,24 +738,17 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<
)
)
} catch (error) {
// Why: gate bookkeeping must never fail daemon init; stale seeds only
// defer a usage refresh until the next restart.
// Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart.
console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error)
}
}
// Why: the Manage Sessions IPC handlers need read access to the current
// adapter/router to list sessions, kill them, etc. Exposed as a narrow getter
// rather than exporting the module-level variable to keep the "swap on
// restart" invariant in one place (replaceDaemonProvider).
// Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider).
export function getDaemonProvider(): DaemonProvider | null {
return adapter
}
// Why: the "Restart daemon" flow rebuilds the current-protocol adapter and
// must update both the module-level `adapter` singleton here and the
// `localProvider` reference inside ipc/pty.ts. Without this helper they could
// drift — app-quit would dispose a stale adapter reference.
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
setLocalPtyProvider(newAdapter)
@@ -892,11 +782,7 @@ export type RestartDaemonResult = {
killedCount: number
}
// Why: the 7-step sequence from docs/daemon-staleness-ux.md §Phase 1 restart.
// Current-protocol only — legacy adapters are preserved and route to their
// original daemons with no respawn path. See the design doc for rationale on
// each step, notably why synthetic exits must fan out *before* the listener
// unsubscribe.
// Why: the 7-step restart sequence from docs/daemon-staleness-ux.md §Phase 1; current-protocol only (legacy adapters preserved).
export async function restartDaemon(): Promise<RestartDaemonResult> {
if (restartInFlight) {
return restartInFlight
@@ -918,11 +804,7 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
const currentOnly = getCurrentDaemonAdapter(currentAdapter)
const legacyAdapters = getLegacyDaemonAdapters(currentAdapter)
// Step 1: synthesize pty:exit for every active session on the current
// adapter BEFORE any teardown. The daemon's kill-all-and-shutdown path
// explicitly does not fan onExit to clients (session.ts:246-252), so
// without this the renderer would never see exits and would black-hole
// writes against the disposed adapter.
// Step 1: synthesize pty:exit for every active session BEFORE teardown — the daemon's shutdown path never fans onExit to clients (session.ts:246-252), so the renderer would otherwise never see exits.
const fallbackKilledCount =
currentAdapter instanceof DegradedDaemonPtyProvider
? await currentAdapter.shutdownFallbackSessions()
@@ -939,31 +821,24 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
currentAdapter.fanoutCurrentDaemonSyntheticExits(-1)
}
// Step 2: detach renderer listeners from the current adapter. Must happen
// AFTER step 1 so the synthesized exits actually reach the renderer, and
// BEFORE step 6 so the new provider isn't bound with stale listeners.
// Step 2: detach renderer listeners — after step 1 (so synthesized exits land) and before step 6 (no stale binding).
unbindLocalProviderListeners()
// Step 3: kill the current-protocol daemon process (shutdown RPC → fallback
// killStaleDaemon → socket/pid unlink). Legacy adapters untouched.
// Step 3: kill the current-protocol daemon process; legacy adapters untouched.
let info: Awaited<ReturnType<DaemonSpawner['ensureRunning']>>
try {
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
// Step 4: reuse the existing spawner so the respawn closure baked into
// long-lived adapters stays valid. Do NOT construct a new DaemonSpawner.
// Step 4: reuse the existing spawner so the respawn closure baked into long-lived adapters stays valid (do NOT new one).
currentSpawner.resetHandle()
info = await currentSpawner.ensureRunning()
} catch (error) {
// Why: the old provider remains authoritative until the final swap, even
// if cleanup or replacement launch fails after listener teardown.
// Why: old provider stays authoritative until the final swap; rebind since relaunch failed after teardown.
rebindLocalProviderListeners()
throw error
}
// Step 5: build a fresh current adapter against the respawned daemon. Its
// respawn callback closes over the same spawner instance (identical to the
// crash-respawn closure in initDaemonPtyProvider).
// Step 5: build a fresh current adapter against the respawned daemon.
const newCurrent = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
@@ -977,14 +852,11 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
})
let newProvider: DaemonProvider = newCurrent
try {
// Why: the temporary launcher lease overlaps this permanent pair so manual
// restart cannot strand a newly spawned daemon during adoption.
// Temporary launcher lease overlaps this permanent pair so a manual restart can't strand a newly spawned daemon during adoption.
await newCurrent.establishLifecycleLease()
releaseDaemonAdoptionLease(currentSpawner.getHandle())
// Re-wrap in router if there were legacy adapters at startup; otherwise
// point straight at the new adapter. Legacy instances are preserved by
// reference — they still route to the same pre-upgrade daemons.
// Re-wrap in a router only if legacy adapters exist; they're preserved by reference and still route to their pre-upgrade daemons.
newProvider =
legacyAdapters.length > 0
? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters })
@@ -1002,8 +874,7 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
} catch (caught) {
cleanupError = caught
}
// Why: the previous provider remains module-authoritative until the swap;
// restore its renderer bindings when replacement adoption fails.
// Previous provider stays module-authoritative until the swap; restore its renderer bindings when adoption fails.
rebindLocalProviderListeners()
if (cleanupError) {
throw new AggregateError([error, cleanupError], 'Daemon restart and cleanup both failed')
@@ -1011,12 +882,7 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
throw error
}
// Why: drain the outgoing router's subscriptions from the shared legacy
// adapters before installing the new router (which subscribes fresh). Must
// run *after* the new provider exists so no adapter event is unhandled in
// the narrow window, and *before* replaceDaemonProvider so the swap is
// atomic from the renderer's perspective. Plain dispose() would also tear
// down the legacy adapters themselves — use the router-only variant.
// Drain the old router's subscriptions via the router-only variant (plain dispose() would tear down the shared legacy adapters), after the new provider exists (no unhandled events) and before the swap (atomic for the renderer).
disposeProviderSubscriptionsOnly(currentAdapter)
// Step 6: swap module state (adapter + localProvider) atomically.
@@ -1028,10 +894,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
return { killedCount }
}
// Why: disconnect from the daemon without killing it. The daemon runs as a
// separate process and survives app quit — sessions stay alive for warm
// reattach on next launch. Leave history sessions marked "unclean" here so a
// later daemon crash while Orca is closed is still recoverable on next launch.
// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach.
// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable.
export async function disconnectDaemon(): Promise<void> {
await adapter?.disconnectOnly()
adapter = null
@@ -1046,11 +910,9 @@ export async function shutdownDaemon(): Promise<void> {
}
export type OrphanedDaemonCleanupResult = {
/** True when we detected a live daemon socket and connected to tear it down.
* False when no daemon was running (fresh install or clean previous quit). */
/** True when a live daemon socket was found and torn down; false when none was running. */
cleaned: boolean
/** Number of live PTY sessions killed during cleanup. The caller surfaces this
* to the user so they know what background work was stopped. */
/** Number of live PTY sessions killed during cleanup (surfaced to the user). */
killedCount: number
}
@@ -1065,12 +927,10 @@ export async function cleanupDaemonForProtocol(
const alive = await probeSocket(socketPath)
if (!alive) {
if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
// Why: endpoint absence does not prove a canonical PID record still
// belongs to the current protocol; the exact daemon owns its artifact cleanup.
// Endpoint absence doesn't prove the PID record belongs to the current protocol; leave artifact cleanup to the owning daemon.
return { cleaned: false, killedCount: 0 }
}
// Why: still best-effort remove a stale socket file so a future opt-in
// launch doesn't hit EADDRINUSE when the daemon tries to bind.
// Best-effort remove a stale socket so a future launch doesn't hit EADDRINUSE on bind.
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
@@ -1097,18 +957,13 @@ export async function cleanupDaemonForProtocol(
.catch(() => ({ sessions: [] }))
killedCount = sessions.sessions.filter((s) => s.isAlive).length
// Why: the daemon exposes a single-shot `shutdown` RPC (daemon-server.ts)
// that kills every session and then terminates its own process. Using it
// avoids the race between per-session `kill` calls and the daemon exiting.
// Use the single-shot `shutdown` RPC (kills all sessions then exits) to avoid racing per-session `kill` calls against the daemon exiting.
await client.request('shutdown', { killSessions: true }).catch(() => {
// Daemon exits immediately after handling the RPC the socket may close
// before the reply round-trips. Treat that as success.
// Daemon exits immediately after the RPC, so the socket may close before the reply arrives; treat as success.
})
didRequestShutdown = true
} catch {
// Why: previous-protocol daemons may be wedged or too old to complete the
// RPC cleanup path. Fall back to PID cleanup, but daemon-health only
// unlinks a live socket after proving it killed the matching process.
// Previous-protocol daemons may be wedged or too old for the RPC path; fall back to PID cleanup (only unlinks a live socket after proving the process is killed).
didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)
} finally {
client.disconnect()
@@ -1116,16 +971,13 @@ export async function cleanupDaemonForProtocol(
if (didRequestShutdown && protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
if (!(await waitForDaemonEndpointExit(socketPath))) {
// Why: never fork a replacement while the old incarnation may still
// own the endpoint or be disposing terminal children.
// Never fork a replacement while the old incarnation may still own the endpoint or be disposing terminal children.
throw new Error('Timed out waiting for daemon self-shutdown')
}
return { cleaned: true, killedCount }
}
// Why: after `shutdown`, the daemon unlinks its socket itself — but on some
// crash paths the file lingers. Clean up defensively so a later opt-in
// relaunch can bind cleanly.
// Defensively unlink the socket: the daemon normally removes it after `shutdown`, but on some crash paths it lingers and blocks a later rebind.
if (didRequestShutdown && process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
@@ -1174,13 +1026,7 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
if (!(await probeSocket(socketPath))) {
// Why: dead legacy daemons leave pid/token files behind forever (one per
// protocol bump). A stale pid eventually gets recycled by an unrelated
// process, turning any future identity check into a PowerShell spawn.
// Only clean up when the pid-file process is provably gone: a live
// legacy daemon can transiently fail the 1s probe right after an update
// (wedged event loop, exhausted pipe backlog), and deleting its token
// file would make its sessions permanently unadoptable.
// Why: a recycled stale pid later turns an identity check into a PowerShell spawn, so delete leaked pid/token files — but only when the pid-process is provably gone (a live daemon can transiently fail the probe, and dropping its token makes its sessions permanently unadoptable).
if (!legacyDaemonProcessMayBeAlive(runtimeDir, protocolVersion)) {
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
@@ -1202,17 +1048,8 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
}
continue
}
// Why: old daemon PTYs can be running long-lived agents during an app
// upgrade. Keep those sessions routed to their original daemon while new
// terminals use the current protocol, instead of killing background work.
// Legacy adapters intentionally do not respawn: respawning an old protocol
// daemon from new code would recreate stale env semantics and can be less
// predictable than letting the session fail if that old daemon dies.
// Why historyPath is still passed: checkpoint writes will fail silently
// (pre-v4 daemons don't support getSnapshot), but the HistoryManager is
// still needed for cleanup — close/exit events must remove history dirs
// and mark meta.json as ended. Without it, a later v4 session reusing
// the same ID could false-restore stale scrollback.bin.
// Keep old-protocol PTYs routed to their original daemon during upgrade; legacy adapters never respawn (new code would recreate stale env semantics).
// historyPath is still needed for cleanup — without it a later v4 session reusing the same ID could false-restore stale scrollback.bin.
adapters.push(
new DaemonPtyAdapter({
socketPath,
+31 -96
View File
@@ -42,8 +42,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
// Why: getCwd falls back to OS pid lookup; a plausible fake pid can
// collide with an unrelated local process and leak its cwd into tests.
// Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd.
pid: 999_999_999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
@@ -341,8 +340,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
adapter.pauseProducer(id)
await waitFor(() => lastSubprocess.pause.mock.calls.length > 0)
// Drop the daemon out from under the adapter: the in-flight pause has no
// matching resume anymore.
// Drop the daemon out from under the adapter: the in-flight pause now has no matching resume.
await server.shutdown()
await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected())
@@ -487,27 +485,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(await adapter.getAppliedSize(id)).toEqual({ cols: 120, rows: 40 })
})
// Why: this is the regression the fix targets. resize() is a fire-and-forget
// notify; a resize that arrives after the session exited is silently dropped
// daemon-side. getAppliedSize must keep reporting the last size the PTY
// genuinely took (the stale pre-exit dims) rather than the dropped request —
// so the renderer can tell its requested size never landed. The old
// requested-size cache would have masked the drop.
// Why: a resize after exit is a dropped fire-and-forget notify; getAppliedSize must report the PTY's last real size, not the drop.
it('does not advance when a resize is dropped after the session exited', async () => {
const { id } = await adapter.spawn({ cols: 200, rows: 50 })
// Simulate the child exiting, then a late narrow resize racing in. The
// daemon Session.resize early-returns for an exited session, so the child
// is never resized and the applied size never becomes the requested 80×24.
// Child exits, then a late narrow resize races in; daemon Session.resize early-returns for an exited session so 80×24 never lands.
lastSubprocess._simulateExit(0)
await new Promise((r) => setTimeout(r, 50))
adapter.resize(id, 80, 24)
await new Promise((r) => setTimeout(r, 50))
// The drop must be visible: the subprocess was never resized to the narrow
// dims the renderer requested, and getAppliedSize never reports 80 cols —
// it stays wide (or null once reaped), never masking the drop as "applied".
// The drop must stay visible: never resized to 80 cols, and getAppliedSize never reports 80 (stays wide, or null once reaped).
expect(lastSubprocess.resize).not.toHaveBeenCalledWith(80, 24)
const applied = await adapter.getAppliedSize(id)
expect(applied?.cols).not.toBe(80)
@@ -545,8 +534,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(lastSubprocess.forceKill).toHaveBeenCalled()
})
// Why: shutdown can be the first lazy-client operation after restart; it
// must connect before killing so a healthy session is not orphaned (#7742).
// Why: shutdown can be the first lazy-client op after restart; connect before killing or a healthy session is orphaned (#7742).
it('kills a live session from a fresh adapter that has not connected yet', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
@@ -583,9 +571,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
describe('sessionsNeedingFullCheckpoint cleanup (leak regression)', () => {
// Why: the cold-restore path flags a session for a full checkpoint. If the
// session exits before that checkpoint lands, the flag was never cleared and
// leaked a permanent Set entry for the daemon's lifetime.
// Why: cold-restore flags a session for a full checkpoint; exiting before it lands leaked a permanent Set entry for the daemon's life.
it('clears the pending full-checkpoint flag when a session exits', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const internals = adapter as unknown as { sessionsNeedingFullCheckpoint: Set<string> }
@@ -834,11 +820,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('evicts oldest tombstone when exceeding limit', async () => {
// Why: MAX_TOMBSTONES is 1000, but spawning that many real sessions is
// slow. Instead verify the eviction logic by spawning a small batch and
// checking the oldest tombstone is gone after crossing the cap. We access
// the private map size via the public API: the oldest session should
// become spawnable again once evicted.
// Why: MAX_TOMBSTONES is 1000; spawning that many is slow, so verify eviction with a small batch via the public spawn API.
const ids: string[] = []
for (let i = 0; i < 5; i++) {
const id = `evict-${i}`
@@ -859,8 +841,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })
await adapter.shutdown(ids[0], { immediate: true })
// First tombstone was re-added at the end of the Map, so eviction
// order is now [evict-1, evict-2, evict-3, evict-4, evict-0]
// First tombstone was re-added at the Map's end, so eviction order is now [evict-1, evict-2, evict-3, evict-4, evict-0]
await expect(adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })).rejects.toThrow(
'was explicitly killed'
)
@@ -909,13 +890,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('kills sessions whose id does not match the minted format, even if id is in valid set', async () => {
// Why: parsePtySessionId rejects bare UUIDs (no `@@`) and ids without
// the `::` worktree shape. Such sessions can't be attributed to any
// current worktree and must be treated as orphans regardless of
// valid-set membership. Passing the bare-uuid as a member of
// validWorktreeIds proves the new strict parser short-circuits the
// membership check — under the old loose parser this session would
// have been kept.
// Why: parsePtySessionId rejects ids with no worktree shape, so they're orphaned regardless of valid-set membership.
const sessionId = 'bare-uuid-no-separators'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
@@ -963,8 +938,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('hello from pty\r\n')
await new Promise((r) => setTimeout(r, 50))
// Why: checkpoint-based persistence does not write on every data event.
// No scrollback.bin should exist — checkpoints write checkpoint.json on a timer.
// Why: checkpoint-based persistence writes checkpoint.json on a timer, never scrollback.bin per data event.
expect(existsSync(join(historyDir, getHistorySessionDirName(id), 'scrollback.bin'))).toBe(
false
)
@@ -988,8 +962,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await new Promise((r) => setTimeout(r, 80))
// Why: idle terminals can be numerous. A periodic pass with no data
// must not serialize every live daemon session just because it exists.
// Why: idle terminals can be numerous; a periodic pass with no data must not serialize every live session.
expect(appendSpy).not.toHaveBeenCalled()
lastSubprocess._simulateData('new output\r\n')
@@ -997,8 +970,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [
{ kind: 'output', data: 'new output\r\n' }
])
// Why: the periodic tick must persist increments, never re-serialize
// the full emulator buffer (the issue #5096 stall).
// Why: the periodic tick must persist increments, never re-serialize the full emulator buffer (the issue #5096 stall).
expect(checkpointSpy).not.toHaveBeenCalled()
const logPath = join(historyDir, getHistorySessionDirName(id), 'output.log')
await waitFor(() => {
@@ -1125,15 +1097,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set(['hot']))
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
// Second tick inside the cooldown: the overflow defers and flags the
// session; no snapshot write.
// Second tick inside the cooldown: the overflow defers and flags the session; no snapshot write.
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set())
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
expect(internals.sessionsNeedingFullCheckpoint.has('hot')).toBe(true)
const requestsAfterSecondTick = internals.client.request.mock.calls.length
// Ticks 3..24 (a hot session over ~2 minutes): flagged + cooling down
// short-circuits with ZERO daemon RPCs and zero disk writes.
// Ticks 3..24 (a hot session over ~2 minutes): flagged + cooling down short-circuits with zero daemon RPCs and zero disk writes.
for (let i = 0; i < 22; i++) {
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set())
}
@@ -1152,8 +1122,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await internals.checkpointSessions(['hot'])
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
// Cooldown is active, but quit/sleep-time persistence must not be
// deferred — stale-on-crash is acceptable, stale-on-clean-exit is not.
// Cooldown is active, but quit/sleep persistence must not defer — stale-on-crash is acceptable, stale-on-clean-exit is not.
await expect(internals.checkpointSessions(['hot'], { final: true })).resolves.toEqual(
new Set(['hot'])
)
@@ -1297,8 +1266,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json')
const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')
// Why: keep-history sleep stays unclean so cold restore remains eligible;
// the final checkpoint is the deterministic handoff signal.
// Why: keep-history sleep stays unclean so cold restore stays eligible; the final checkpoint is the deterministic handoff signal.
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
'first cycle content'
@@ -1532,11 +1500,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('cold-restores an alt-screen agent snapshot as scrollback on wake (hibernation)', async () => {
// Why: agent hibernation force-kills Claude/Codex while still in their
// alt-screen TUI, so scrollbackAnsi is empty. The fix falls back to the
// saved snapshot so the pane repaints the agent's last frame instead of
// coming back blank. (The payload is snapshotAnsi alone — no
// rehydrateSequences — so it never re-enters alt-screen.)
// Why: hibernation force-kills the agent in alt-screen (empty scrollbackAnsi); fall back to the snapshot so the pane repaints instead of blanking.
const sessionId = 'cold-restore-alt-screen'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
@@ -1578,14 +1542,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore).toBeDefined()
expect(result.coldRestore!.scrollback).toContain('Claude Code')
// The payload must NOT re-enter alt-screen — that would fight the
// relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET.
// The payload must NOT re-enter alt-screen — it would fight the relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET.
expect(result.coldRestore!.scrollback).not.toContain('\x1b[?1049h')
})
it('skips cold restore for an alt-screen session with an empty snapshot', async () => {
// Why: alt-screen entered before any content → nothing to show. Keep the
// no-op (blank) rather than fabricate a payload.
// Why: alt-screen entered before any content → nothing to show; keep the no-op rather than fabricate a payload.
const sessionId = 'cold-restore-alt-screen-empty'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
@@ -1661,10 +1623,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('revived session output\r\n')
await waitFor(() => checkpointSpy.mock.calls.length === 1)
// Why: appending the fresh session's records to the pre-crash log
// would be rejected by the sequence check on a second crash, reverting
// the restore to pre-crash content. The full checkpoint resets the log
// to a new generation.
// Why: appending fresh records to the pre-crash log would fail the sequence check on a second crash; a full checkpoint resets the log to a new generation.
expect(appendSpy).not.toHaveBeenCalled()
expect(checkpointSpy).toHaveBeenCalledWith(
sessionId,
@@ -1703,14 +1662,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastFullCheckpointAt: Map<string, number>
sessionsNeedingFullCheckpoint: Set<string>
}
// A daemon respawn inside one adapter keeps this map: seed a fresh
// cooldown as if the pre-crash generation just snapshotted.
// A daemon respawn inside one adapter keeps this map, so seed a fresh cooldown as if the pre-crash generation just snapshotted.
internals.lastFullCheckpointAt.set(sessionId, Date.now())
await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
// The revived generation has no checkpoint of its own — the re-anchor
// must not inherit the previous generation's cooldown.
// The revived generation has no checkpoint of its own — the re-anchor must not inherit the previous generation's cooldown.
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
})
@@ -1721,10 +1678,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await first.spawn({ cols: 80, rows: 24, sessionId })
first.dispose()
// A fresh adapter (app relaunch) attaches to the still-live daemon
// session. The old adapter may have drained records it never persisted
// (deferred hot-session tick), so appends must not resume until a full
// snapshot re-anchors the log.
// The old adapter may have drained records it never persisted (deferred hot-session tick), so appends must wait for a full snapshot to re-anchor the log.
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const internals = historyAdapter as unknown as {
sessionsNeedingFullCheckpoint: Set<string>
@@ -1740,9 +1694,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const sessionId = 'warm-reattach-skip-replay'
const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId })
// Why disconnectOnly: the production app-quit path leaves meta.endedAt
// null so the session stays crash-recoverable — the state every app
// relaunch with a live daemon sees.
// Why disconnectOnly: the app-quit path leaves meta.endedAt null, keeping the session crash-recoverable like every relaunch with a live daemon.
await first.disconnectOnly()
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
@@ -1800,12 +1752,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
).client
const originalRequest = client.request.bind(client)
// Why: simulates the probe→createOrAttach race — the probe sees the
// session alive, but it is gone by the time createOrAttach runs. The
// meta rewrite mimics the dying session's exit event beating the
// createOrAttach reply and writing endedAt via closeSession; the
// fallback detect must still restore instead of falling through to
// openSession (which would delete the checkpoint).
// Why: simulates the probe→createOrAttach race (session dies mid-call, writing endedAt); fallback detect must still restore, not fall through to openSession which deletes the checkpoint.
vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
if (type === 'getSize') {
return { size: { cols: 100, rows: 30 } }
@@ -1831,8 +1778,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.coldRestore).toBeDefined()
expect(result.coldRestore!.scrollback).toContain('raced output')
// The unseeded race winner is replaced before exposure, so the retained
// shell uses the recovered dimensions as well as the recovered history.
// The unseeded race winner is replaced before exposure, so the retained shell uses recovered dimensions as well as history.
expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 100, rows: 30 })
// The recovery data must survive — openSession would have deleted it.
expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true)
@@ -1868,8 +1814,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
).client
const originalRequest = client.request.bind(client)
// Why: an old daemon rejects the unknown getSize method; the spawn must
// behave exactly like the unprobed path.
// Why: an old daemon rejects the unknown getSize method; the spawn must behave exactly like the unprobed path.
vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => {
if (type === 'getSize') {
return Promise.reject(new Error('Unknown request type'))
@@ -2005,9 +1950,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await new Promise((r) => setTimeout(r, 50))
// Why: checkpoint-based persistence opens the session for future
// checkpointing but does not seed scrollback.bin. New data is persisted
// via periodic checkpoint timer, not per-chunk appendData.
// Why: checkpoint-based persistence doesn't seed scrollback.bin — new data lands via the periodic checkpoint timer, not per-chunk appendData.
const meta = JSON.parse(
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'meta.json'), 'utf-8')
)
@@ -2184,10 +2127,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('treats a hello handshake timeout as daemon-gone and respawns (#8689)', async () => {
// Why: a wedged daemon accepts the socket connection but never answers
// hello, so ensureConnected() rejects with "Hello response timed out".
// That must be classified as daemon-gone so withDaemonRetry respawns and
// retries — otherwise every terminal spawn fails against the wedge forever.
// Why: a wedged daemon accepts the socket but never answers hello; classify as daemon-gone so withDaemonRetry respawns, else every spawn fails forever.
const realEnsureConnected = DaemonClient.prototype.ensureConnected
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
@@ -2355,12 +2295,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
// Why: the restart flow (docs/daemon-staleness-ux.md §Phase 1 step 1) relies
// on these two primitives to fan synthetic pty:exit out to every attached
// session *before* tearing the adapter down. The design doc calls out
// session.ts:246-252 as the reason — the daemon's kill-all-and-shutdown
// explicitly does NOT fan exits back through onExit. Without the fanout the
// renderer would black-hole writes against a disposed adapter.
// Why: daemon kill-all-and-shutdown doesn't fan exits through onExit, so these primitives synthesize pty:exit before dispose, else the renderer black-holes writes (docs/daemon-staleness-ux.md §Phase 1).
describe('fanoutSyntheticExits / getActiveSessionIds (restart primitives)', () => {
it('reports every live spawn in getActiveSessionIds', async () => {
const { id: id1 } = await adapter.spawn({ cols: 80, rows: 24 })
+99 -293
View File
@@ -1,6 +1,4 @@
/* oxlint-disable max-lines -- Why: history error-logging .catch() chains add ~10 lines of
safety wiring spread across spawn/event-routing; splitting would scatter tightly coupled
adapter ↔ history lifecycle logic. */
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import { DaemonClient } from './client'
@@ -47,8 +45,7 @@ type ColdRestorePayload = {
}
function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
// Why: alt-screen snapshots represent the TUI buffer; prefer its normal
// scrollback so a dead TUI is not revived as the fresh shell's active screen.
// Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen.
return restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
@@ -69,11 +66,9 @@ export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
protocolVersion?: number
/** Directory for disk-based terminal history. When set, the adapter writes
* raw PTY output to disk for cold restore on daemon crash. */
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
historyPath?: string
/** Called when the daemon socket is unreachable (process died). Expected to
* fork a fresh daemon so the next connection attempt can succeed. */
/** Called when the daemon socket is unreachable; forks a fresh daemon so the next connect can succeed. */
respawn?: () => Promise<void | (() => void)>
}
@@ -104,10 +99,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
private respawnFn: (() => Promise<void | (() => void)>) | null
private pendingRespawnAdoptionRelease: (() => void) | null = null
private respawnAdoptionClosed = false
// Why: multiple pane mounts can call spawn() concurrently. If the daemon is
// dead, all calls enter withDaemonRetry's catch block at once. Without a
// lock, each would fork its own daemon process. This promise coalesces
// concurrent respawns so only the first caller forks; the rest await it.
// Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it.
private respawnPromise: Promise<void> | null = null
private dataListeners: ((payload: {
id: string
@@ -121,54 +113,32 @@ export class DaemonPtyAdapter implements IPtyProvider {
private removeEventListener: (() => void) | null = null
private initialCwds = new Map<string, string>()
private wslDistrosBySessionId = new Map<string, string>()
// Why: React re-renders and StrictMode double-mounts can call createOrAttach
// for a session the user just killed. Without tombstones, the daemon would
// create a fresh session — resurrecting a terminal the user explicitly closed.
// Uses a Map<id, timestamp> so eviction removes the oldest by insertion order,
// matching terminal-host.ts tombstone semantics.
// Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts).
private killedSessionTombstones = new Map<string, number>()
// Why: React StrictMode double-mounts: mount → cold restore → unmount →
// mount → ??? The sticky cache returns the same cold restore data on the
// second mount until the renderer explicitly acknowledges it.
// Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it.
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
// log belong to the pre-crash session. Incremental appends would land on
// that stale log (and be rejected by its sequence check on restore), so the
// first tick must re-anchor with a full snapshot checkpoint, which resets
// the log to a new generation.
// Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot.
private sessionsNeedingFullCheckpoint = new Set<string>()
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
private checkpointInFlight: Promise<void> | null = null
// Why: checkpoint-based persistence requires the getSnapshot RPC (v4+).
// Legacy daemons reject it, causing noisy log spam every 5 seconds.
// Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s.
private supportsCheckpoints: boolean
// Why: incremental checkpoints require the takePendingOutput RPC (v13+).
// Against older daemons the tick falls back to full-snapshot checkpoints.
// Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints.
private supportsIncrementalCheckpoints: boolean
// Why: producer pause/resume notifications require v19+; legacy daemons
// must never see them, so gating makes them silent no-ops there.
// Why: producer pause/resume notifications require v19+; gate them to silent no-ops on legacy daemons.
private supportsProducerFlowControl: boolean
private supportsAuthoritativeBufferSnapshots: boolean
private supportsStartupIngress: boolean
private pausedProducerSessionIds = new Set<string>()
// Why tracked here: the daemon's background set (keep-tail stream thinning
// + transient-fact scan authority) dies with the daemon process/socket;
// re-sync it on a fresh connection so hidden panes stay thinned.
// Why tracked here: the daemon's background set dies with the daemon process/socket; re-sync on a fresh connection so hidden panes stay thinned.
private backgroundedSessionIds = new Set<string>()
// Why: a daemon that survives a socket drop can still hold a pause whose
// resume died with the connection. Owe those sessions a resume on the next
// connect; the daemon's 5s failsafe covers the window in between.
// Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap).
private producerResumesOwedOnReconnect = new Set<string>()
private static CHECKPOINT_INTERVAL_MS = 5_000
// Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB
// snapshot checkpoint on every 5s tick via pending-buffer overflow or the
// log-size cap — hundreds of MB/min of disk writes from one busy terminal.
// Bounding cap/overflow-triggered snapshots per session trades bounded
// cold-crash scrollback staleness (warm reattach and final checkpoints are
// unaffected and bypass this) for a ~9x cut in worst-case write volume.
// Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness).
private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
private lastFullCheckpointAt = new Map<string, number>()
@@ -247,22 +217,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
await this.ensureConnected()
// Why before createOrAttach: a preserved v19 daemon may remember this
// session as backgrounded. Ordered control delivery clears it before any
// newly attached stream bytes can be thinned without a recoverable seq.
// Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq.
if (!this.supportsAuthoritativeBufferSnapshots) {
this.setPtyBackgrounded(sessionId, false)
}
// Why: detect crash-recovery history before spawning a replacement PTY so
// the revived shell inherits the recovered cwd and dimensions instead of
// whatever the current renderer happened to request on mount.
// Why probe aliveness first: detectColdRestore synchronously replays the
// full checkpoint + log (up to ~5MB) through a scratch emulator on the
// main process, but a live daemon session ignores spawn params and its
// own snapshot supersedes disk — the replay result would be discarded.
// getSize is a read-only probe; on error/unsupported it degrades to the
// full detect.
// Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request.
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
if (this.historyReader?.hasRestorableHistory(sessionId)) {
@@ -301,11 +262,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
launchAgent: opts.launchAgent,
// Why: without this, the daemon always spawns cmd.exe (COMSPEC) or
// PowerShell as a fallback — regardless of which shell the renderer
// asked for in the "+" menu or persisted as the default. Forwarding
// the override makes the daemon path behave the same as the in-process
// LocalPtyProvider.
// Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider.
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation,
@@ -320,8 +277,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
let result = await createOrAttach(scrollback)
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
// Why: explicit null from a current daemon overrides the caller's WSL
// preference; undefined preserves compatibility with older daemons.
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
wslDistro = providerWslDistro ?? undefined
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
@@ -335,20 +291,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.initialCwds.set(sessionId, effectiveCwd)
}
// Why: the daemon RPC returns the shell pid of the backing subprocess.
// Surfacing it through PtySpawnResult lets ipc/pty register with the
// memory collector without a provider-specific accessor.
// Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor.
let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
// Why: check sticky cache first — StrictMode double-mounts call spawn
// twice. The second call finds an existing daemon session (isNew=false)
// but should still return the cached cold restore data.
// Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data.
const cachedRestore = this.coldRestoreCache.get(sessionId)
if (cachedRestore) {
// Why: wake after sleep also lands here, and the slept session's active
// tracking and history writer were dropped when sleep killed the PTY.
// Without re-registering both, checkpoints stop after wake and the
// second sleep/wake cycle restores a blank terminal.
// Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal.
this.activeSessionIds.add(sessionId)
if (this.historyManager) {
this.historyManager.reopenSession(sessionId)
@@ -363,20 +312,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
// Why: the probe→createOrAttach gap is racy — the session can exit (or
// enter termination) in between, so the daemon spawned a fresh shell.
// Detect now so scrollback restore matches the unprobed path; only the
// new shell's cwd/dims came from the renderer request in this rare case.
// Why ignoreCleanEnd: the raced session's exit event (stream socket) can
// beat the createOrAttach reply and write endedAt via closeSession; that
// must not null the restore here, or the openSession branch below would
// delete the checkpoint instead of restoring it.
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (result.isNew && restoreSkippedForLiveSession) {
restoreInfo = detectColdRestore({ ignoreCleanEnd: true })
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
if (restoreInfo && scrollback) {
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
// Why: the aliveness probe raced with session death, so the first create lacked recovery bytes; replace it before exposing the PTY.
await this.client.request('kill', { sessionId, immediate: true })
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
@@ -401,26 +343,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceForSpawn(result)
// Cold restore: daemon created a new session but disk history shows
// an unclean shutdown → return saved scrollback so the renderer can
// display the previous terminal content.
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
const coldRestore = this.buildColdRestorePayload(restoreInfo)
const canReanchorHistory = !scrollback || result.historySeeded === true
// Why: use registerWriter (not openSession) to avoid deleting the
// existing checkpoint.json. If the revived daemon crashes again before
// the next 5s tick, the checkpoint is the only recovery data available.
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
if (this.historyManager) {
if (canReanchorHistory) {
this.historyManager.registerWriter(sessionId)
this.sessionsNeedingFullCheckpoint.add(sessionId)
// Why: the revived generation has no valid checkpoint of its own; a
// cooldown inherited from the pre-crash generation (daemon respawn
// within one adapter) must not defer this re-anchor.
// Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor.
this.lastFullCheckpointAt.delete(sessionId)
} else {
// Preserve the old recovery files when the new daemon cannot include
// them; a fresh-only checkpoint would make the data loss permanent.
// Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent.
this.historyManager.suspendSession(sessionId)
}
}
@@ -454,21 +389,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
.catch((err) => console.warn('[history] openSession failed:', sessionId, err))
} else if (this.historyManager && result.historySeeded === false) {
// Why: the daemon keeps this failure bit with the live session, so a new
// adapter cannot promote its fresh-only snapshot after an app restart.
// Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart.
this.historyManager.suspendSession(sessionId)
} else if (this.historyManager) {
// Why: on warm reattach after app relaunch, the HistoryManager is a
// fresh instance with no writers. registerWriter adds the writer
// without overwriting meta.json or deleting the existing checkpoint
// (which is the only valid recovery data until the next tick).
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
this.historyManager.registerWriter(sessionId)
if (!wasAlreadyManaged) {
// Why: a previous adapter may have drained daemon records it never
// persisted (a deferred hot-session tick) before the app died.
// Appending increments past that unknown drain point would put a seq
// gap in the log, which the restore reader rejects wholesale. Force a
// full snapshot to re-anchor before any further appends.
// Why: a previous adapter may have drained records it never persisted, so appending would leave a seq gap the reader rejects; force a full snapshot to re-anchor.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
@@ -491,10 +418,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
result.snapshot.scrollbackAnsi +
result.snapshot.rehydrateSequences +
result.snapshot.snapshotAnsi
// Why kitty flags ride beside the payload, not inside it: the snapshot
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
// deliberate kitty reset must win. Only the runtime emulator re-seed
// consumes the flags (terminal-query-authority.md §kitty).
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
id: sessionId,
@@ -510,9 +434,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
: {}),
isReattach: true,
isAlternateScreen: isAltScreen,
// Why: carry the mid-escape tail so the renderer can write it after the
// reattach reset — without it the local daemon reattach path renders a
// split escape's continuation literally, unlike the remote path (#7329).
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
...(result.snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: result.snapshot.pendingEscapeTailAnsi }
: {})
@@ -563,14 +485,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.notify('resumePty', { sessionId: id })
}
// Why fire-and-forget (like pausePty): a delivery hint for the daemon's
// keep-tail stream thinning.
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
setPtyBackgrounded(id: string, background: boolean): void {
if (!this.supportsProducerFlowControl) {
return
}
// Why: preserved v19 daemons can thin but cannot return the absolute
// snapshot sequence needed to recover a gap. Clear their stale hint too.
// Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too.
const safeBackground = this.supportsAuthoritativeBufferSnapshots && background
if (safeBackground) {
this.backgroundedSessionIds.add(id)
@@ -613,8 +533,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
this.sleepRestoreSessionIds.add(id)
// Why: physical exit must not mark intentional sleep as a clean end;
// the final checkpoint remains the wake-time recovery authority.
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
this.historyManager?.suspendSession(id)
}
}
@@ -629,28 +548,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.coldRestoreCache.delete(id)
this.sleepRestoreSessionIds.delete(id)
}
// Why: the !keepHistory close path doesn't take a final checkpoint, so a
// session stranded in sessionsNeedingFullCheckpoint would never be cleared.
// (Under keepHistory the final checkpoint above already cleared the flag, so
// this is a harmless no-op there — kept unconditional to cover both paths.)
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
this.sessionsNeedingFullCheckpoint.delete(id)
this.lastFullCheckpointAt.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
this.wslDistrosBySessionId.delete(id)
// Why: history removal is for the "user explicitly closed this terminal"
// path. Sleep also calls shutdown but expects scrollback to survive — wake
// re-spawns and the cold-restore reader needs the dir intact. Caller
// indicates intent via opts.keepHistory.
// Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory).
if (this.historyManager && !opts.keepHistory) {
void this.historyManager
.removeSession(id)
.catch((err) => console.warn('[history] removeSession failed:', id, err))
}
// Why: tombstone rejects reattach against a session the user explicitly
// killed. Sleep legitimately reattaches on wake, so skip both the LRU bump
// and the size-cap eviction under keepHistory.
// Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory.
if (!opts.keepHistory) {
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
@@ -673,13 +584,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
// (no rehydrateSequences — they start with \x1b[?1049h, which the
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
// normal scrollback. An empty snapshot still yields null → no-op.
// Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake.
const scrollback = restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
@@ -714,12 +619,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.initialCwds.get(id) ?? ''
}
// Why: resize() is a fire-and-forget notify, so a resize can be dropped
// daemon-side (session not yet alive, exited, invalid dims, cold-restore
// snapshot-col coercion) without the renderer knowing. This reads the size
// the daemon actually applied so the renderer can detect that drift on resume
// and re-assert. Null (RPC failure / unknown session) means "cannot confirm",
// which the renderer treats as a cue to re-forward once.
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
@@ -745,8 +645,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons have no absolute output sequence. Their snapshot
// cannot safely reconcile stream bytes still queued on the other socket.
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
@@ -781,8 +680,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
async hasChildProcesses(id: string): Promise<boolean> {
const foregroundProcess = await this.getForegroundProcess(id)
// Why: daemon-backed PTYs can host long-lived agents while the renderer is
// detached. Cleanup prompts must not treat those sessions like idle shells.
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
@@ -822,16 +720,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Sessions already live in the daemon — no revival needed
}
/** Called on app launch. Lists daemon sessions, kills orphans whose
* workspaceId no longer exists, and caches alive session IDs.
/** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId
* no longer exists, and caches alive session IDs.
*
* IMPORTANT: a session id embeds the worktree id it was minted under, which is
* the worktree's *path* at spawn time. When a worktree folder is renamed, its
* id changes but live sessions keep the old id. Callers MUST therefore seed
* `validWorktreeIds` with each live worktree's `WorktreeMeta.priorWorktreeIds`
* (the pre-rename aliases) or those sessions will be reaped as false orphans.
* This reconcile has no production caller yet; wire the alias in when it gains
* one. */
* IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed
* worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live
* worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false
* orphans. No production caller yet; wire the alias in when it gains one. */
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
@@ -846,9 +741,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!session.isAlive) {
continue
}
// Why: session IDs use the format `${worktreeId}@@${shortUuid}`. Sessions
// whose id does not match the minted format (worktreeId === null) cannot
// be tied to a live worktree and are treated as orphans.
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
@@ -860,10 +753,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
killed.push(session.sessionId)
} else {
alive.push(session.sessionId)
// Why: background sessions discovered here may produce output before
// the user reattaches their pane. Without adding them to the checkpoint
// set, disconnectOnly()'s final checkpoint would skip them, leaving
// stale recovery data if the daemon later crashes.
// Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data.
this.activeSessionIds.add(session.sessionId)
this.historyManager?.registerWriter(session.sessionId)
}
@@ -885,18 +775,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
.filter((s) => s.isAlive)
.map((s) => ({
id: s.sessionId,
// Why: OSC 7 may not arrive before destructive cleanup. Spawn cwd is
// still authoritative ownership until the daemon reports a live cwd.
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
title: 'shell',
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}))
}
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
// createdAt) per session for display; listProcesses drops that detail for
// the IPtyProvider contract. Keep both in parallel rather than widening
// the provider surface.
// Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract.
async listSessions(): Promise<SessionInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
@@ -907,13 +793,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
return [...this.activeSessionIds]
}
// Why: used by the "Restart daemon" handler to synthesize pty:exit for every
// live session *before* tearing down the adapter. The daemon's own
// kill-all-and-shutdown path explicitly suppresses onExit fanout
// (session.ts:246-252), so without this the renderer panes would black-hole
// writes to a disposed adapter forever. Reuses the existing exitListeners
// path so downstream cleanup (clearProviderPtyState, markClaudePtyExited,
// renderer pty:exit) runs exactly as it does on natural exit.
// Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit
// for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever.
fanoutSyntheticExits(code: number): void {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
@@ -925,10 +806,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.stopCheckpointTimer()
for (const id of ids) {
this.coldRestoreCache.delete(id)
// Why: listener throws are intentionally *not* caught — matches the
// natural onExit fanout in setupEventRouting, so synthetic exits don't
// diverge in error semantics from real ones. A throwing listener is a
// bug that should surface loudly, not be silently swallowed.
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id, code })
@@ -1008,9 +886,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.producerResumesOwedOnReconnect.clear()
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side in TerminalHost.dispose()
// which has direct access to sessions. The adapter only marks sessions as
// cleanly ended here so they don't trigger false cold restores.
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
// cleanly ended so they don't trigger false cold restores.
if (this.historyManager) {
void this.historyManager
.dispose()
@@ -1023,39 +900,28 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
return
}
// Why: an authenticated pair cancels the launch-adoption watchdog and gives
// a never-used adapter authority to retire its empty daemon during clean quit.
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
await this.client.ensureConnected()
}
// Why: for in-process daemon mode, disconnect without flushing history.
// dispose() writes endedAt for all sessions, which would prevent cold
// restore. disconnectOnly() leaves history files in unclean state so
// the next launch detects them as crash-recoverable.
// We write a final checkpoint before disconnecting so that if the daemon
// later crashes while Orca is closed, checkpoint.json has recovery data.
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
async disconnectOnly(): Promise<void> {
this.respawnAdoptionClosed = true
this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
// Why: wait for any in-flight timer pass to finish before starting
// the final checkpoint. Otherwise both passes race on the shared tmp
// file, risking ENOENT on rename and disabling future writes.
// Why: wait out an in-flight timer pass; racing on the shared tmp file risks ENOENT on rename, disabling future writes.
if (this.checkpointInFlight) {
await this.checkpointInFlight
}
// Why: without a final checkpoint, sessions opened after the last timer
// tick have no checkpoint.json on disk. If the detached daemon later
// dies, detectColdRestore finds nothing to restore from. Must await
// before disconnecting — fire-and-forget would race with client.disconnect()
// and the pending getSnapshot RPCs would be rejected.
// Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon
// later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs.
await this.checkpointAllSessions()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
// Why: the detached daemon keeps these PTYs alive for warm reattach; a
// pause left behind would block their shells for a failsafe window.
// Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window.
for (const id of this.pausedProducerSessionIds) {
this.client.notify('resumePty', { sessionId: id })
}
@@ -1065,16 +931,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.removeEventListener = null
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
try {
// Why: only the authenticated daemon can atomically prove it is empty;
// one shared budget keeps a first connection plus retirement off quit's critical path.
// Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path.
const deadlineMs = Date.now() + 250
if (!this.client.isConnected()) {
await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
}
await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now()))
} catch {
// An unreachable daemon falls back to event-driven retirement when its
// authenticated sockets close and it can prove itself empty.
// An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty.
}
}
this.client.disconnect()
@@ -1088,14 +952,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
: this.client.ensureConnected())
} finally {
// Why: a respawn launcher holds a temporary full pair until this adapter
// has attempted its permanent reconnect, preventing both gaps and leaks.
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
this.releasePendingRespawnAdoptionLease()
}
// Why sampled before setupEventRouting: routing is (re)installed exactly
// once per connection, so "no listener yet" identifies a fresh connect —
// the only time the daemon-side backgrounded set needs a resync (it is
// process state that died with the previous daemon/socket).
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
const isFreshConnection = this.removeEventListener === null
this.setupEventRouting()
this.scheduleCheckpointTimer()
@@ -1117,8 +978,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
for (const id of this.producerResumesOwedOnReconnect) {
// Why: resuming a session the fresh daemon doesn't know is a harmless
// no-op; leaving a survivor paused would waste 5s of failsafe latency.
// Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency.
this.client.notify('resumePty', { sessionId: id })
}
this.producerResumesOwedOnReconnect.clear()
@@ -1147,14 +1007,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
) {
return
}
// Why: checkpointing is only needed after terminal data/resize/write marks
// a session dirty. A permanent interval woke the main process every 5s for
// idle daemon-backed terminals just to discover there was nothing to write.
// Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write.
this.checkpointTimer = setTimeout(() => {
this.checkpointTimer = null
// Why: if the previous pass is still in-flight (slow RPC or disk),
// retry later instead of overlapping checkpoint() writes to the same tmp
// file, which can lose a rename and disable future history writes.
// Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes.
if (this.checkpointInFlight) {
this.scheduleCheckpointTimer()
return
@@ -1178,10 +1034,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!this.historyManager || this.dirtySessionVersions.size === 0) {
return
}
// Why: getSnapshot serializes the daemon's terminal buffer. On large
// workspaces, checkpointing every live idle session every 5s burns CPU and
// disk for identical payloads; dirty versions keep retries precise without
// dropping writes that arrive during an in-flight checkpoint.
// Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces)
// while not dropping writes that arrive mid-checkpoint.
const versions = new Map(
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
)
@@ -1199,14 +1053,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.stopCheckpointTimerIfIdle()
}
// Why: the adapter runs in the Electron main process and does not have direct
// access to daemon Session objects. It calls checkpoint RPCs over the daemon
// socket per session. Returns a promise that resolves when all checkpoint
// writes complete (callers that don't need to wait can void it).
// Why final=true here: this runs on clean disconnect, where the full-depth
// snapshot (not the increment log) must be the restore source. It is not a
// teardown snapshot: the detached daemon and its PTYs keep running for warm
// reattach, so shell-ready scanner state must remain intact.
// Why final=true not teardown: clean disconnect needs the full-depth snapshot as the restore source, but the
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
private async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
for (const sessionId of completed) {
@@ -1238,8 +1086,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
teardown: opts?.teardown === true
})
.then((result) => {
// Why: deferred sessions stay dirty so the checkpoint timer keeps
// retrying until their full-snapshot cooldown expires.
// Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires.
if (result === 'done') {
completed.add(sessionId)
}
@@ -1247,9 +1094,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
}
}
// Why: snapshot serialization and checkpoint writes are CPU/disk heavy.
// Dirty-session filtering keeps idle terminals out; this cap prevents one
// tick from snapshotting every active dirty terminal at once.
// Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once.
const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () =>
checkpointNext()
)
@@ -1257,27 +1102,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
return completed
}
// Why cooldown starts only after a session's FIRST full snapshot: a session
// with no checkpoint on disk yet must be able to write one immediately or a
// cold restore would find nothing.
// Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately.
private isFullCheckpointCoolingDown(sessionId: string): boolean {
const last = this.lastFullCheckpointAt.get(sessionId)
if (last === undefined) {
return false
}
const elapsed = Date.now() - last
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not
// extend the deferral window.
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window.
return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS
}
// Why 'deferred' exists: a cap/overflow-triggered full snapshot inside the
// cooldown window is postponed, and the session must STAY dirty so the 5s
// timer keeps retrying until the cooldown expires. While deferred, no
// takePendingOutput/append runs for the session — appending past a dropped
// range would leave a hole in the log, whereas skipping keeps the on-disk
// state a consistent (merely stale) prefix that the eventual full snapshot
// re-anchors.
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
private async checkpointSession(
sessionId: string,
opts: { final: boolean; teardown: boolean }
@@ -1293,12 +1130,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) {
return 'deferred'
}
// Why take-with-snapshot instead of plain getSnapshot: the take clears
// the daemon's pending records in the same synchronous turn as the
// serialize. A plain snapshot would leave pre-snapshot records pending;
// a later warm reattach would append them to the fresh log and cold
// restore would replay them on top of a checkpoint that already
// contains them.
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown })
this.sessionsNeedingFullCheckpoint.delete(sessionId)
return 'done'
@@ -1310,8 +1143,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return 'done'
}
if (take.overflowed) {
// Why: overflow dropped records, so the log has a hole only a full
// snapshot (which reflects everything ever written) can re-anchor it.
// Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
@@ -1331,8 +1163,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
take.records
)
if (appendResult === 'needs-checkpoint') {
// Why dropping take.records is lossless: they were applied to the live
// emulator before the take, so the snapshot below contains them.
// Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
@@ -1355,27 +1186,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.historyManager.checkpoint(sessionId, take.snapshot)
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0) {
// Why: take-with-snapshot usually returns no records because the
// snapshot subsumes them. Held parser-state bytes, such as an
// incomplete shell-ready marker prefix, are not representable in the
// snapshot and must remain as a post-checkpoint log tail.
// Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
}
}
// Why: when the daemon process dies, operations fail with ENOENT (socket
// gone), ECONNREFUSED, or "Connection lost" (socket closed mid-request).
// Rather than leaving all terminals permanently broken until app restart,
// this wrapper detects daemon-death errors, tears down the stale client
// state, forks a fresh daemon via respawnFn, reconnects, and retries the
// operation once. If respawn itself fails, the error propagates normally.
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.
private async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
// Why: self-retirement removes the token only after an authenticated
// endpoint dropped; an initial missing token may still hide a live daemon.
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
const missingRetiredEndpointToken =
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
if (
@@ -1394,8 +1216,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
try {
return await fn()
} finally {
// Why: the retried operation may reject before it reaches a connection
// attempt (for example, a tombstone racing respawn).
// Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn).
this.releasePendingRespawnAdoptionLease()
}
}
@@ -1426,8 +1247,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
// Why: replacing the daemon kills its sessions without daemon-side exit
// fanout. Emit exits first so renderer panes do not write to dead PTYs.
// Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs.
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
@@ -1463,8 +1283,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.disconnect()
const releaseAdoptionLease = await this.respawnFn!()
if (this.respawnAdoptionClosed) {
// Why: app teardown may win while the launcher is still acquiring its
// temporary pair; a late result must not reinstall a lease nobody owns.
// Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns.
releaseAdoptionLease?.()
throw new Error('Daemon adapter closed during respawn')
}
@@ -1529,21 +1348,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
} else if (event.event === 'exit') {
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
// Why: an exited session must not be owed a resume on reconnect — a
// reused sessionId would receive a stray resumePty. Same for the
// background set: a reused id must start un-thinned.
// Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state.
this.pausedProducerSessionIds.delete(event.sessionId)
this.producerResumesOwedOnReconnect.delete(event.sessionId)
this.backgroundedSessionIds.delete(event.sessionId)
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
this.coldRestoreCache.delete(event.sessionId)
}
// Why: an exited session can never be checkpointed again, so its pending
// full-checkpoint flag is dead state. Without this, a cold-restored
// session that exits before its first checkpoint leaks a permanent entry.
// Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak.
this.sessionsNeedingFullCheckpoint.delete(event.sessionId)
// Why: a reused sessionId (renderer respawns a persisted ptyId) must
// not inherit the dead session's snapshot cooldown.
// Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown.
this.lastFullCheckpointAt.delete(event.sessionId)
this.stopCheckpointTimerIfIdle()
if (this.historyManager) {
@@ -1572,16 +1386,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
// Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is
// unreachable (daemon died). Checking syscall avoids false positives from
// token-file ENOENT (readFileSync), which has no syscall or syscall='open'.
// "Connection lost" / "Not connected" mean the daemon died while we had an
// active or stale connection. "Hello response timed out" means we reconnected
// to a daemon whose socket accepts connections but whose event loop never
// answers the handshake (a wedged daemon, #8689) — respawning re-enters the
// grace-bounded launcher, which drains a transient wedge or replaces a
// permanent one instead of failing every terminal forever. All indicate the
// daemon is unusable and a respawn should be attempted.
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
function isDaemonGoneError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
+36 -89
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this class owns the daemon socket protocol,
request routing, stream fanout, and session lifecycle in one place so
renderer/daemon request semantics stay auditable across platform branches. */
/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */
import { createServer, type Server, type Socket } from 'node:net'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
@@ -84,8 +82,7 @@ type PendingShutdownReply = {
}
export class DaemonServer {
// Why: a new daemon must survive long enough for its first client pair, but
// a parent crash between launch and adoption must not orphan it forever.
// Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first.
private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
private server: Server | null = null
@@ -130,10 +127,7 @@ export class DaemonServer {
}
}
)
// Fact scan authority for backgrounded sessions — facts ride the stream
// queue as control entries so they hold byte order with the data around
// them (a fact jumping the queue could arrive after the reveal snapshot
// that already reflects it).
// Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot).
private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => {
const clientId = this.streamClientIdBySessionId.get(sessionId)
if (clientId) {
@@ -150,10 +144,7 @@ export class DaemonServer {
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
private stopStreamBacklogProbe: () => void = () => {}
// Why: main-process PTY IPC has the same recent-input bypass, but daemon
// output reaches main only after this stream layer. Keeping the window here
// removes the daemon's fixed batch delay from keystroke echo/redraws while
// preserving batching for background and large output.
// Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay.
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
@@ -207,8 +198,7 @@ export class DaemonServer {
this.server.once('error', onListenError)
this.server.listen(this.socketPath, () => {
// Why: after bind, steady-state socket errors are handled per client;
// the startup promise listener would otherwise retain this closure.
// Why: drop the startup error listener after bind so it doesn't retain this closure.
this.server?.off('error', onListenError)
writeFileSync(this.tokenPath, this.token, { mode: 0o600 })
try {
@@ -217,8 +207,7 @@ export class DaemonServer {
// Best-effort on platforms that support it
}
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
// Why: a parent crash before the first full client pair must not leave
// a freshly published, empty daemon alive forever.
// Why: a parent crash before the first full client pair must not leave an empty daemon alive forever.
this.armInitialAdoptionTimeout()
}
resolve()
@@ -249,8 +238,7 @@ export class DaemonServer {
}
private unlinkOwnedEndpointArtifacts(): void {
// Why: close has already fenced this endpoint, but ownership checks still
// prevent a late replacement's canonical token or PID record from removal.
// Why: ownership checks prevent removing a late replacement's token or PID record.
unlinkOwnedDaemonTokenFile(this.tokenPath, this.token)
if (this.pidPath && this.launchNonce) {
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
@@ -264,8 +252,7 @@ export class DaemonServer {
try {
await this.host.dispose()
} catch (err) {
// Why: an unreapable child must not block daemon exit — after exit it
// reparents to init, while a blocked daemon would orphan alongside it.
// Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
@@ -291,11 +278,9 @@ export class DaemonServer {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
// Why: call close synchronously before any awaited cleanup so no new
// transport can enter after the idle fence is proven empty.
// Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof.
server.close(() => {
// Node owns unlinking its Unix listener. An extra check-then-unlink here could
// delete a replacement endpoint installed concurrently after close.
// Node owns unlinking its Unix listener; an extra unlink here could delete a concurrent replacement.
resolve()
})
})
@@ -363,16 +348,14 @@ export class DaemonServer {
}
this.idleShutdownState = 'idle-shutdown-pending'
if (!this.isIdle()) {
// Why: work admitted before the fence wins. Clearing the pending state
// keeps that already-started client/session fully usable.
// Why: work admitted before the fence wins; clear pending state to keep it usable.
this.idleShutdownState = 'running'
this.reevaluateIdleShutdown()
return
}
this.idleShutdownState = 'shutting-down'
// beginServerClose() runs synchronously up to server.close(), before host
// disposal or file cleanup can yield to a racing connection.
// beginServerClose() runs synchronously up to server.close() before any yield to a racing connection.
const serverClose = this.beginServerClose()
this.shutdownPromise = this.finishIdleShutdown(serverClose)
}
@@ -395,8 +378,7 @@ export class DaemonServer {
socket.on('error', () => socket.destroy())
if (this.idleShutdownState !== 'running') {
// Why: an accepted connection queued just before server.close() must get
// an explicit retry signal instead of appearing authenticated then dying.
// Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth.
socket.end(
encodeNdjson({
type: 'hello',
@@ -407,8 +389,7 @@ export class DaemonServer {
)
return
}
// Why: clients can send multibyte prompt/input text split across socket
// chunks; keep UTF-8 sequences intact before NDJSON parsing.
// Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleFirstMessage(socket, msg, parser),
@@ -479,24 +460,20 @@ export class DaemonServer {
this.setupControlSocket(socket, hello.clientId)
if (previous) {
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
// Why: a reconnect can reuse a clientId before the old sockets notice
// their close. Tear them down after installing the new owner so stale
// close events cannot delete the replacement client entry.
// Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement.
previous.streamSocket?.destroy()
previous.controlSocket.destroy()
}
} else if (hello.role === 'stream') {
const client = this.clients.get(hello.clientId)
if (!client) {
// Why: stream sockets are only meaningful beside a control socket; an
// orphan stream would otherwise stay open with no tracked owner.
// Why: a stream socket is meaningless without its control socket; drop the orphan.
socket.destroy()
return
}
this.setupStreamSocket(socket, client)
client.authenticatedPairEstablished = true
// A complete app connection, unlike a health or raw socket probe, owns
// the endpoint again and cancels pending event-driven retirement.
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
@@ -504,8 +481,7 @@ export class DaemonServer {
}
private setupControlSocket(socket: Socket, clientId: string): void {
// Why: terminal writes and startup commands can contain emoji/Unicode.
// Decoding per Buffer would corrupt split multibyte sequences.
// Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleRequest(socket, clientId, msg as DaemonRequest),
@@ -538,8 +514,7 @@ export class DaemonServer {
) {
return
}
// Why: once the last full client is gone, exact daemon-side emptiness is
// sufficient; incomplete transports may block but never erase this request.
// Why: once the last full client is gone, incomplete transports may block retirement but never erase it.
this.retirementRequested = true
}
@@ -566,8 +541,7 @@ export class DaemonServer {
socket.on('error', cleanup)
if (previous && previous !== socket) {
// Why: replacing a stream socket must not leave the old receive-only
// channel alive and untracked.
// Why: replacing a stream socket must not leave the old channel alive and untracked.
previous.destroy()
}
}
@@ -628,8 +602,7 @@ export class DaemonServer {
this.shutdownPromise = finish()
}
}
// Why: a non-reading authenticated peer must not pin a fenced daemon by
// holding its acknowledgement behind permanent socket backpressure.
// Why: a non-reading peer must not pin a fenced daemon by holding its ack behind permanent socket backpressure.
timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
timer.unref()
socket.once('close', start)
@@ -643,8 +616,7 @@ export class DaemonServer {
pending.add(preparation)
this.pendingPtySpawnPreparations.set(sessionId, pending)
try {
// Why: registration precedes the async capability probe so a concurrent
// close can cancel this exact creation before a subprocess exists.
// Why: register before the async probe so a concurrent close can cancel this creation before a subprocess exists.
await this.preparePtySpawn()
if (preparation.canceled) {
throw new TerminalAttachCanceledError(sessionId)
@@ -683,8 +655,7 @@ export class DaemonServer {
throw new Error('Daemon temporarily unavailable; reconnect')
}
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
// Why: a control-only replacement cannot own terminal admission or
// erase the prior full client's monotonic retirement request.
// Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request.
throw new Error('Daemon client connection is incomplete; reconnect')
}
this.createOrAttachInFlight++
@@ -701,8 +672,7 @@ export class DaemonServer {
envToDelete: p.envToDelete,
command: p.command,
startupCommandDelivery: p.startupCommandDelivery,
// Why: daemon RPC payloads are untrusted JSON. Persist only the
// allowlisted enum used for byte routing, never arbitrary identity.
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
shellOverride: p.shellOverride,
terminalWindowsWslDistro: p.terminalWindowsWslDistro,
@@ -721,8 +691,7 @@ export class DaemonServer {
: {}),
streamClient: {
onData: (data, rawLength = data.length, transformed = false, seq) => {
// Scan BEFORE enqueue: the batcher may keep-tail drop this
// chunk, but its facts must be captured regardless.
// Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless.
this.transientFactRelay.onSessionData(p.sessionId, data)
const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
const isInteractiveOutput =
@@ -738,9 +707,7 @@ export class DaemonServer {
})
},
onExit: (code) => {
// Why: exit tears down renderer handlers, so it must ride the
// ordered queue behind final output even when the shallow socket
// gate holds that output for a later drain pass.
// Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output.
this.log.log('session-exited', { sessionId: p.sessionId, code })
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
type: 'event',
@@ -764,9 +731,7 @@ export class DaemonServer {
this.reevaluateIdleShutdown()
}
this.streamClientIdBySessionId.set(p.sessionId, clientId)
// Why an attach-time marker: the adapter resyncs the background set on
// a fresh connection, which can precede this attach — main's scan
// suppression must still start at the head of the new stream.
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
if (this.transientFactRelay.isBackgrounded(p.sessionId)) {
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
type: 'event',
@@ -837,9 +802,7 @@ export class DaemonServer {
return {}
}
if (background) {
// Prime the fresh relay tracker with the emulator's dangling
// incomplete escape so a sequence split across the handoff parses
// exactly as if the relay had seen the whole stream.
// Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses.
this.transientFactRelay.seedSessionScanState(
sessionId,
this.host.getPartialEscapeTailAnsi(sessionId)
@@ -850,11 +813,7 @@ export class DaemonServer {
// Not attached yet — the attach-time marker covers the handoff.
return {}
}
// Reveal deliberately does NOT discard or force-flush the queued
// tail: main's model (hidden-output recovery buffer, tail previews)
// needs those bytes — a finished program's last output lives there —
// and the normal flush/drain loop delivers them within milliseconds
// (bounded ≤ the keep-tail drop cap), in order, ahead of the marker.
// Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker.
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
type: 'event',
@@ -880,8 +839,7 @@ export class DaemonServer {
try {
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
} catch (error) {
// Why: a kill that wins before session registration has already
// canceled the pending spawn and therefore completed its intent.
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
throw error
}
@@ -894,8 +852,7 @@ export class DaemonServer {
return {}
case 'detach':
// Note: detach token handling is simplified here — full implementation
// would track tokens per client
// Note: detach token handling simplified — full impl would track tokens per client
this.log.log('session-detached', { sessionId: request.payload.sessionId })
return {}
@@ -937,8 +894,7 @@ export class DaemonServer {
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
// Why: close before acknowledging retirement so no new terminal can
// race between the empty proof and daemon disposal.
// Why: close before acknowledging retirement so no new terminal races between the empty proof and disposal.
const serverClose = this.beginServerClose()
this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () =>
this.finishIdleShutdown(serverClose)
@@ -956,9 +912,7 @@ export class DaemonServer {
const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows })
const snapshotMs = performance.now() - snapshotStart
if (snapshotMs >= 25) {
// Serialize stalls block the daemon's single thread — every pty's
// echo included. Surfaced here so multi-second typing stalls can be
// attributed to checkpoint storms (issue #5096 family) in the field.
// Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family).
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
sessionIdSuffix: request.payload.sessionId.slice(-10),
snapshotMs: Math.round(snapshotMs)
@@ -971,10 +925,7 @@ export class DaemonServer {
return { size: this.host.getAppliedSize(request.payload.sessionId) }
case 'takePendingOutput':
// Why no await before this call: with includeSnapshot, drain and
// serialize must share one synchronous turn — an intervening await
// would let PTY data land in between, and cold restore would replay
// those bytes on top of a snapshot that already contains them.
// Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes.
return this.host.takePendingOutput(
request.payload.sessionId,
request.payload.includeSnapshot === true,
@@ -1001,9 +952,7 @@ export class DaemonServer {
try {
await this.host.dispose()
} catch (err) {
// Why: the shutdown RPC contract is that the daemon always
// self-terminates; dispose keeps failed owners retryable, and the
// follow-up shutdown() below retries them once more before exit.
// Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
@@ -1031,9 +980,7 @@ export class DaemonServer {
if (!client?.streamSocket) {
return
}
// Why: write/resize are notification-heavy and intentionally do not wait
// for replies. If their target session is gone, this synthetic exit is the
// only signal the renderer gets to clear stale terminal pane bindings.
// Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings.
this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
type: 'event',
event: 'exit',
+19 -70
View File
@@ -19,58 +19,27 @@ type StreamDataClient = {
streamSocket: Socket | null
}
// Why 2ms: under continuous agent output every chunk waits an expected
// half-window here AND again in main's PTY batch — at 8ms each that was
// ~8ms of the measured ~19ms DSR-under-load latency. 2ms keeps burst
// coalescing (~500 socket writes/s worst case, ~100B framing overhead per
// write against MB/s payloads) while cutting the fixed latency tax 4x.
// 2ms: each chunk waits a half-window here AND again in main's PTY batch; a smaller interval still coalesces bursts while cutting the fixed latency tax (~8ms of the measured ~19ms DSR-under-load latency).
const STREAM_DATA_BATCH_INTERVAL_MS = 2
// Why a shallow socket: the stream socket is one FIFO for every session, and
// bytes already written can never be overtaken — a deep user-space buffer
// buries a visible pane's keystroke echo behind bulk output for other panes
// (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at
// this depth and the remainder is HELD here, where the interactive
// flushSession path can still jump it; socket 'drain' refills. Echo latency
// is then bounded by the shallow depth, not by how much bulk is in flight.
// 128KB must stay above the socket's ~16KB highWaterMark so a held state
// implies a false write() and therefore a guaranteed 'drain' wake-up.
// Kill switch: ORCA_DAEMON_SHALLOW_SOCKET_GATE=0 restores pre-gate unbounded
// socket writes for field debugging and true fix-off A/B benches.
// Shallow socket: the stream is one FIFO, so a deep buffer buries a visible pane's echo behind other panes' bulk; bulk stops here and is HELD (flushSession can jump it), bounding echo latency.
// 128KB stays above the socket's ~16KB highWaterMark so a held state implies a false write() and thus a guaranteed 'drain' wake-up.
const SHALLOW_SOCKET_WRITE_GATE_BYTES =
process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024
// Why sliced writes: enqueue coalesces per-session entries, so a held entry
// can grow to megabytes; writing it whole would re-deepen the socket past the
// gate in one call.
// Sliced writes: a coalesced entry can grow to megabytes; writing it whole would re-deepen the socket past the gate in one call.
const BULK_WRITE_SLICE_CHARS = 64 * 1024
// Safety valve: if held bulk ever exceeds this, write through to the socket
// (exactly the pre-gate behavior) — bounded daemon memory beats bounded echo
// latency in the extreme. Must sit FAR above the pacer's pause watermark plus
// its overshoot (observed ~5MB with 17 paused sessions' in-flight pty reads):
// an engaged valve deepens the socket and buries interactive echo behind the
// whole backlog (measured as bimodal ~2.4s key medians when this was 8MB).
// Safety valve: past this, write through — bounded daemon memory beats bounded echo latency in the extreme. Must sit FAR above the pacer's pause watermark + overshoot (~5MB) or an engaged valve buries interactive echo behind the whole backlog.
const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
// Why a small-session bypass: the hold is there to stop FLOODS from burying
// everyone else; a session with only a few KB queued (keystroke echo, prompt
// redraws, query replies) is never the flood and must not wait FIFO behind
// other sessions' megabytes. The daemon's 100ms interactive fast-path is a
// heuristic that misses under event-loop load (measured: echo classified
// non-interactive rode the held queue for ~2.4s); this bypass is the
// deterministic backstop. Worst socket over-deepening per flush is
// sessions × this ≈ tens of KB.
// Small-session bypass: a few-KB session (echo, redraws, query replies) is never the flood, so it must not wait FIFO behind others' megabytes; backstops the 100ms interactive fast-path, which misses under event-loop load.
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
type DaemonStreamDataBatcherOptions = {
maxLineBytes?: number
/** Fires after each stream-socket write — the only place backlog grows, so
* the backlog pacer checks its watermark here. */
/** Fires after each stream-socket write — the only place backlog grows, so the backlog pacer checks its watermark here. */
onAfterSocketWrite?: () => void
/** True for sessions whose queued output may be keep-tail dropped
* (main-marked background sessions). */
/** True for sessions whose queued output may be keep-tail dropped (main-marked background sessions). */
isSessionDroppable?: (sessionId: string) => boolean
/** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC color probes) out
* of dropped data — the hidden program blocks on the reply, so those few
* bytes must still be delivered even when their flood is not. */
/** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC probes) out of dropped data — the hidden program blocks on the reply, so they must still be delivered even when their flood is not. */
salvageDroppedData?: (dropped: string) => string
}
@@ -108,8 +77,7 @@ export class DaemonStreamDataBatcher {
appendDaemonStreamData(batch, sessionId, data, options)
if (this.isSessionDroppable(sessionId)) {
// Keep-tail scales down as more backgrounded sessions queue, bounding
// the AGGREGATE a reveal must drain (see daemon-stream-keep-tail-drop).
// Keep-tail scales down as more backgrounded sessions queue, bounding the aggregate a reveal must drain (see daemon-stream-keep-tail-drop).
const droppableQueued = this.countDroppableSessionsWithQueuedData(batch)
const dropCap = backgroundSessionDropCapChars(droppableQueued)
const keepTail = backgroundSessionKeepTailChars(droppableQueued)
@@ -117,8 +85,7 @@ export class DaemonStreamDataBatcher {
dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData)
}
if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) {
// The shared budget tightened: re-trim sessions that already finished
// producing — they never re-enter this path on their own.
// Shared budget tightened: re-trim sessions that already finished producing — they never re-enter this path on their own.
for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) {
if (
queued > dropCap &&
@@ -145,8 +112,7 @@ export class DaemonStreamDataBatcher {
}
}
/** Append a pre-shaped stream event at the current position in the
* session's byte order (scan handoff markers, gaps, transient facts). */
/** Append a pre-shaped stream event at the current position in the session's byte order (scan handoff markers, gaps, transient facts). */
enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void {
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
@@ -195,23 +161,19 @@ export class DaemonStreamDataBatcher {
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
// Same as pre-gate behavior: a vanished stream socket drops the batch —
// the model owns the bytes and reconnect restores from a snapshot.
// A vanished stream socket drops the batch — the model owns the bytes and reconnect restores from a snapshot.
this.pendingByClient.delete(clientId)
return
}
const socket = client.streamSocket
// Sessions that held an entry must hold ALL their later entries in this
// pass — writing around a held entry would reorder that session's bytes.
// A session that held an entry must hold all its later entries this pass — writing around a held entry would reorder that session's bytes.
const heldSessions = new Set<string>()
const retained: PendingStreamDataBatch['queue'] = []
while (batch.queue.length > 0) {
const entry = batch.queue[0]
if (entry.control) {
// Control entries only respect the held-session order latch — they
// are ~100B, so writing them onto a deep socket is as harmless as the
// small-session bypass.
// Control entries only respect the held-session order latch; at ~100B, writing them onto a deep socket is as harmless as the small-session bypass.
if (heldSessions.has(entry.sessionId)) {
retained.push(entry)
batch.queue.shift()
@@ -226,19 +188,14 @@ export class DaemonStreamDataBatcher {
if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) {
const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0
if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) {
// Hold this flooding session's entry; small talkers keep flowing.
// The socket's 'drain' (routed back to flush by the server)
// resumes held bulk. No timer: a deep socket implies a prior
// false write(), so 'drain' is guaranteed.
// Hold this flooding session's entry; small talkers keep flowing. No timer: a deep socket implies a prior false write(), so 'drain' (routed back to flush) is guaranteed to resume held bulk.
heldSessions.add(entry.sessionId)
retained.push(entry)
batch.queue.shift()
continue
}
} else if (socketDeep) {
// Valve engaged: held bulk exceeded the memory cap and is being
// written through onto a deep socket — echo protection is off until
// it drains. Rare enough to be worth a diagnostics event every time.
// Valve engaged: held bulk exceeded the memory cap, so echo protection is off until it drains — rare enough to log every time.
recordDaemonStreamBacklogEvent('heldWriteThrough', {
heldChars: batch.queuedChars,
socketBufferedBytes: socket.writableLength ?? 0
@@ -284,16 +241,7 @@ export class DaemonStreamDataBatcher {
}
if (retained.length > 0) {
batch.queue = retained
// Held entries must not wait for the socket's 'drain' alone: drain only
// fires when the user-space buffer fully EMPTIES, so bulk would advance
// one gate-depth per daemon event-loop turn — seconds of dead time for
// a multi-MB hidden backlog on a busy daemon (measured: hidden-restore
// 2.5s vs the 1.5s budget). Arm ONE ~90B empty data event whose
// kernel-flush callback re-flushes while bytes are still in flight, so
// main never starves. (An empty socket write's callback fires
// immediately — verified — so the sentinel must be a real protocol
// no-op line.) Event-driven, no timers; the per-client latch stops
// sentinel stacking; 'drain' remains the backstop.
// 'drain' only fires when the buffer fully empties (one gate-depth/turn = seconds for multi-MB backlogs); arm a no-op data event whose flush callback re-flushes while bytes are still in flight.
this.armHeldQueueRefill(socket, clientId, retained[0].sessionId)
return
}
@@ -307,6 +255,7 @@ export class DaemonStreamDataBatcher {
return
}
this.refillArmedClients.add(clientId)
// Must be a real protocol no-op line, not an empty write: an empty write's callback fires immediately, defeating the in-flight re-flush.
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
this.refillArmedClients.delete(clientId)
this.flush(clientId)
@@ -19,27 +19,19 @@ import {
writeChunksToTerminal
} from '../../shared/terminal-restore-parity-fixture'
// Differential garble gate for the hidden-terminal model/view contract:
// with the hidden-delivery
// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the
// source of truth and reveal repaints the renderer xterm from
// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot).
// This fuzz feeds seeded agent-TUI byte streams to the production emulator
// and to an always-visible renderer-parity terminal, then asserts the
// serialize→replay round trip reproduces the exact screen the renderer would
// have shown. Any diff = a garble bug on reveal.
// Differential garble gate for the hidden-terminal contract: a hidden pane gets nothing, so main's
// HeadlessEmulator is source of truth and reveal repaints from the snapshot. This fuzz asserts the
// serialize→replay round trip matches an always-visible renderer twin — any diff = a garble bug on reveal.
//
// Runtime knobs:
// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with
// the reveal-reconciliation suite)
// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, keeps combined suite runtime <60s)
// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log)
const DEFAULT_ITERATIONS = 300
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
const ITERATIONS =
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the
// scrollback budget the reveal restore actually requests from main.
// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the reveal restore's scrollback budget.
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
function readPositiveIntEnv(name: string): number | null {
@@ -64,9 +56,7 @@ type FidelityDiff = {
stage: string
expected: unknown
actual: unknown
/** True when the always-visible buffer matches the known upstream
* @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see
* bufferHasSerializeHostileWrappedRow and the skipped repro test below. */
/** True when the buffer trips the known upstream addon-serialize blank-leading-wrapped-row bug (see bufferHasSerializeHostileWrappedRow). */
knownSerializeWrapBug?: boolean
}
@@ -103,8 +93,7 @@ async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | n
}
await writeChunksToTerminal(control.terminal, chunks)
// Stage 1 — model fidelity: the emulator's screen must already match the
// renderer twin before any serialization enters the picture.
// Stage 1 — model fidelity: the emulator's screen must match the renderer twin before any serialization.
const modelDiff = firstDiff(
'model-visible (HeadlessEmulator vs renderer twin)',
visibleRows(control.terminal),
@@ -114,9 +103,7 @@ async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | n
return modelDiff
}
// Stage 2 — reveal round trip: serialize exactly like
// serializeHiddenOutputRecoveryBuffer, replay exactly like
// applyMainBufferSnapshot, then compare against the always-visible twin.
// Stage 2 — reveal round trip: serialize like serializeHiddenOutputRecoveryBuffer, replay like applyMainBufferSnapshot, compare to the always-visible twin.
const alt = emulator.isAlternateScreen
const snapshot = emulator.getSnapshot({
scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
@@ -148,13 +135,7 @@ async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | n
control.terminal.modes.bracketedPasteMode,
restored.terminal.modes.bracketedPasteMode
),
// Why alt is excluded from the two comparisons below:
// - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts)
// deliberately forces scrollbackRows=0 while an alt-screen TUI is
// active, so normal-buffer history is not part of the alt contract.
// - application cursor: HeadlessEmulator.getModes reports
// applicationCursor false on the alternate buffer, so rehydrate omits
// ?1h there by design.
// alt excludes both comparisons below by design: alt forces scrollbackRows=0 (no normal-buffer history) and reports applicationCursor=false (rehydrate omits ?1h).
alt
? null
: firstDiff(
@@ -185,9 +166,7 @@ async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | n
}
}
/** Greedy op-drop minimizer: re-runs the full differential pipeline on
* smaller op lists so a failure report carries the smallest byte stream that
* still diverges (plus its seed for exact replay via FUZZ_SEED). */
/** Greedy op-drop minimizer: shrinks a failing case to the smallest still-diverging op list (with its seed for FUZZ_SEED replay). */
async function minimizeFailure(testCase: FidelityCase): Promise<FidelityCase> {
let current = { ...testCase, chunked: false }
if ((await runFidelityCase(current)) === null) {
@@ -221,12 +200,7 @@ function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): stri
}
describe('headless emulator snapshot fidelity fuzz', () => {
// Known-legitimate divergence, pinned so it cannot silently regress into a
// real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon
// never re-emits OSC 8, and production compensates by shipping the ranges
// out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the
// renderer link provider to re-register. Byte-replay therefore keeps the
// TEXT but not the link underline — the metadata must carry the range.
// Known-legitimate divergence: SerializeAddon never re-emits OSC 8, so byte-replay drops the link underline; production carries the ranges out-of-band in snapshot.oscLinks.
it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => {
const emulator = new HeadlessEmulator({ cols: 60, rows: 10 })
const restored = createRendererParityTerminal({ cols: 60, rows: 10 })
@@ -252,11 +226,7 @@ describe('headless emulator snapshot fidelity fuzz', () => {
})
it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => {
// The known-and-pinned serialize wrap bug (A) is tolerated + counted so
// deep mode (FUZZ_ITERATIONS) surfaces only GENUINELY NEW divergences.
// Bugs B (bold-reset, fixed by the addon patch) and C (margin cursor,
// fixed by the absolute-cursor epilogue) are no longer tolerated — a
// regression fails the corpus loudly and the unskipped repros below.
// Bug A (serialize wrap) is tolerated + counted so deep mode surfaces only new divergences; B and C are fixed, so their regressions fail loudly.
let knownSerializeWrapBugHits = 0
for (let i = 0; i < ITERATIONS; i++) {
const seed = FIXED_SEED ?? 1 + i
@@ -272,44 +242,14 @@ describe('headless emulator snapshot fidelity fuzz', () => {
expect.fail(formatFailure(minimized, minimizedDiff ?? diff))
}
}
// Guard the tolerance from swallowing the suite: the predicate tripping
// on most seeds means the gate has gone degenerate.
// Guard the tolerance: the predicate tripping on most seeds means the gate has gone degenerate.
expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
}, 600_000)
// ── HEADLINE FINDING (do not delete while unfixed upstream) ──────────────
// @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells
// that touch a soft-wrap boundary. Two variants, both found by this fuzz
// and minimized below. Every Orca snapshot consumer is affected: hidden
// reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe
// replay paint lost/shifted characters or stray '-' fillers whenever a TUI
// erased inside a soft-wrapped line (shell line editing, status lines wider
// than the pane, Claude Code in-place prompt redraws).
//
// V1 — cell loss (found by seed 31, minimized to 2 ops):
// Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214)
// nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth
// ? this._nullCellCount <= 1 : this._nullCellCount <= 0
// binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row
// passes as a "natural" wrap. The serializer then emits the previous row as
// full-width text (leaving xterm in wrap-pending) and skips the null cell
// with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of
// crossing the wrap boundary, so the next character overwrites the previous
// row's last cell and the whole tail shifts left by one.
// cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'
// live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12']
// serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D'
// replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted
//
// V2 — stray filler '-' (found by seed 157, minimized below): when the
// SOURCE row of a wrapped pair is entirely null (a TUI erased the whole
// first half of a wrapped line), the addon's forced-wrap "magic" writes
// nullCellCount+1 dashes and then cleans up with
// ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ...
// With length === nullCellCount that cursor-forward becomes `ESC[0C`, and
// CSI param 0 means 1, so the ECH erase lands one cell right and the first
// '-' stays visible on the restored row.
// Unskip once the upstream fix (or a local serialize post-processor) lands.
// HEADLINE FINDING (do not delete while unfixed upstream): @xterm/addon-serialize 0.15.0-beta.287
// doesn't round-trip null cells at a soft-wrap boundary, garbling every snapshot restore of a TUI that
// erased inside a soft-wrapped line. Repros below — V1 cell loss (seed 31, SerializeAddon.ts ~L214
// wrap-validity ternary), V2 stray '-' filler (seed 157). Unskip once the upstream fix lands.
it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
@@ -334,15 +274,13 @@ describe('headless emulator snapshot fidelity fuzz', () => {
}
})
// V2 repro of the headline finding above (stray '-' filler on a fully
// erased wrapped source row). Unskip alongside the V1 repro.
// V2 repro of the headline finding (stray '-' on a fully erased wrapped source row); unskip with V1.
it.skip('round-trips a wrapped line whose source row was fully erased', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
try {
// Wrap a 28-char line, then erase the entire first (source) row of the
// wrapped pair: cursor up twice onto it, EL 2.
// Wrap a 28-char line, then erase its entire first (source) row: cursor up twice, EL 2.
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K']
for (const chunk of bytes) {
await emulator.write(chunk)
@@ -363,20 +301,13 @@ describe('headless emulator snapshot fidelity fuzz', () => {
}
})
// ── Bug B regression guard: SGR bold on a dim→bold-only cell transition ──
// Upstream @xterm/addon-serialize emitted `\x1b[1;22m` for this transition;
// SGR 22 (normalIntensity) clears BOTH bold and dim, so the restored cell
// lost its bold. FIXED by the intensity-group reorder in
// config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch (22 before
// 1/2). Found by fuzz seeds 435, 770, 1321; mechanism in
// notes/garble-fuzz-divergences.md (Bug B).
// Bug B guard: upstream emitted 1;22m and SGR 22 clears both bold and dim, dropping bold; fixed by the intensity-group reorder in the addon-serialize patch. notes/garble-fuzz-divergences.md
it('preserves bold when serializing a dim cell followed by a bold-only cell', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 4 })
const control = createRendererParityTerminal({ cols: 20, rows: 4 })
const restored = createRendererParityTerminal({ cols: 20, rows: 4 })
try {
// 'A' dim, 'B' bold-only. Live: A=dim, B=bold. The patched serializer
// emits 22;1 for the B transition (clear before re-set).
// 'A' dim, 'B' bold-only; patched serializer emits 22;1 for B (clear before re-set).
const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m']
for (const chunk of bytes) {
await emulator.write(chunk)
@@ -396,19 +327,13 @@ describe('headless emulator snapshot fidelity fuzz', () => {
}
})
// ── Bug C regression guard: cursor exact when the last row fills the margin ──
// Upstream @xterm/addon-serialize computes its relative cursor-restore from
// a wrap-pending position and lands one column short. FIXED Orca-side: the
// emulator snapshot appends an absolute CUP from the source's authoritative
// cursor (serializeWithAbsoluteCursor). Found by fuzz seeds 454, 1696;
// mechanism in notes/garble-fuzz-divergences.md (Bug C).
// Bug C guard: upstream serialize lands the cursor one col short (wrap-pending); fixed by absolute-CUP epilogue (serializeWithAbsoluteCursor). notes/garble-fuzz-divergences.md
it('restores the cursor exactly when the last content row fills the right margin', async () => {
const emulator = new HeadlessEmulator({ cols: 10, rows: 4 })
const control = createRendererParityTerminal({ cols: 10, rows: 4 })
const restored = createRendererParityTerminal({ cols: 10, rows: 4 })
try {
// Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a
// known lower-row column. Live cursor is (x=4, y=2).
// Fill row 0 to the margin (wrap-pending), then CUP to a lower row; live cursor is (x=4, y=2).
const bytes = ['0123456789\x1b[3;5H']
for (const chunk of bytes) {
await emulator.write(chunk)
+26 -122
View File
@@ -25,13 +25,7 @@ export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
/** Phase-5 model query responder sink (terminal-query-authority.md).
* When set, xterm-core auto-replies generated while parsing a write
* flagged `forwardQueryReplies` are forwarded here; all other emissions
* (seeds, hydration, snapshot replay, unsolicited core pushes) are
* discarded. The daemon Session must NEVER pass this — its emulator
* stays write-only forever (contract invariant: the daemon never
* answers). */
/** Query reply sink (terminal-query-authority.md); only `forwardQueryReplies` writes emit here. The daemon Session must never pass this. */
onQueryReply?: (reply: string) => void
pathFlavor?: 'posix' | 'win32'
remotePosixFileUriAuthority?: boolean
@@ -39,17 +33,14 @@ export type HeadlessEmulatorOptions = {
}
export type HeadlessEmulatorWriteOptions = {
/** Reply ownership captured at ingestion for this exact chunk. Default
* false is the main-side replay guard (twin of the renderer's
* replay-guard.ts): seed/hydration/snapshot writes never forward. */
/** Reply ownership for this exact chunk; default false so seed/hydration/snapshot writes never forward (main-side replay guard; twin of renderer replay-guard.ts). */
forwardQueryReplies?: boolean
}
type TerminalWithSynchronousWrite = Terminal & {
_core?: {
writeSync?: (data: string) => void
// Why: kitty keyboard flags are not on the public IModes; read the core
// service state the CSI =/>/< u handlers mutate.
// Why: kitty keyboard flags aren't on the public IModes; read the core service the CSI u handlers mutate.
coreService?: {
kittyKeyboard?: { flags?: number }
}
@@ -57,18 +48,12 @@ type TerminalWithSynchronousWrite = Terminal & {
}
const DEFAULT_SCROLLBACK = 5000
// Keep in sync with the renderer twin in terminal-capability-replies.ts
// (main must not import renderer modules).
// Keep in sync with the renderer twin terminal-capability-replies.ts (main must not import renderer modules).
const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
export class HeadlessEmulator {
private terminal: Terminal
private serializer: SerializeAddon
// Why: our restructure owns cwd/title via TerminalOscCwdTitleScanner and the
// DECSET mouse modes via TerminalMouseModeMirror (functionally identical to
// main's inline cwd/lastTitle/oscScanTail + TerminalPrivateModeTracker, which
// only tracks the same mouse modes). restoredOscLinks/disposed/partialEscapeTail
// are declared below.
private oscText: TerminalOscCwdTitleScanner
private mouseModes = new TerminalMouseModeMirror()
private readonly pathFlavor?: 'posix' | 'win32'
@@ -78,15 +63,9 @@ export class HeadlessEmulator {
private onQueryReply: ((reply: string) => void) | null
private conptyDa1OverrideInstalled = false
private viewAttributeResponder: TerminalViewAttributeResponder | null = null
// Why: replies must be scoped to the exact write that carried the query.
// The window opens around the parse of a forward-flagged chunk and closes
// with it, so seeds/snapshots and unsolicited core emissions (e.g. native
// 997 pushes from option mutations) can never leak to the PTY.
// Why: replies must be scoped to the exact write that carried the query, so seeds/snapshots and unsolicited emissions never leak to the PTY.
private queryReplyForwardingDepth = 0
// Why: a chunk ending mid-escape leaves the sequence in xterm's parser, not
// the buffer, so serialize() drops it and the next chunk's continuation
// renders literal after a restore (Bug E, notes/garble-fuzz-divergences.md).
// Committed alongside mouseModes: only after xterm parsed the same bytes.
// Why: a mid-escape chunk tail lives in xterm's parser, not the buffer, so serialize() drops it and it renders literal after restore (Bug E).
private partialEscapeTail = ''
constructor(opts: HeadlessEmulatorOptions) {
@@ -103,51 +82,27 @@ export class HeadlessEmulator {
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
allowProposedApi: true,
logLevel: 'off',
// Why: parity with the renderer's buildDefaultTerminalOptions — parse
// CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app
// actually pushed. Write-only daemon use is unaffected: keyboard state
// never alters serialization (terminal-query-authority.md §kitty).
// Why: parse CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app pushed (renderer parity).
vtExtensions: { kittyKeyboard: true }
})
this.serializer = new SerializeAddon()
this.terminal.loadAddon(this.serializer)
// Why: this mirror must measure character widths exactly like the
// renderer's xterm (Unicode 11 + ZWJ emoji joining). With the default v6
// tables, emoji-dense rows (agent status lines) advance the cursor
// differently here than on screen, so the mirrored buffer accumulates
// cell-shifted tears that snapshot restores then paint back as garbage.
// Why Unicode 11: must match the renderer's char-width measurement, else emoji rows mismeasure and the mirror accumulates cell-shifted tears.
this.terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(this.terminal)
// Why onData is gated behind onQueryReply: by default this emulator is
// pure state tracking and MUST NOT respond to terminal query sequences
// (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data
// in-process synchronously before `handleSubprocessData` forwards it to
// the renderer over IPC, so any reply it emitted would land on the
// shell's stdin ahead of the renderer's xterm reply and win the race —
// a double-reply with default-xterm values (OSC 11 default-black was
// the visible casualty). Only main's runtime per-PTY emulators pass a
// sink, and even then replies flow only for chunks the hidden-delivery
// gate DROPPED, where the renderer never sees the bytes and main is the
// single answerer under main's terminal query authority.
// Why gated: an emulator query reply would beat the renderer's to the shell's stdin (OSC 11 default-black was the casualty).
this.onQueryReply = opts.onQueryReply ?? null
if (this.onQueryReply) {
this.terminal.onData((reply) => this.emitQueryReply(reply))
}
}
/** Main-side twin of the renderer's terminal-capability-replies.ts:
* ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override
* variant (`CSI ?61;4c`) must win. Returning true consumes the query so
* xterm core's default `?1;2c` cannot double-reply (custom CSI handlers
* run before core's; false falls through). The reply still routes through
* the forwarding window, so replayed/seeded bytes never answer. */
/** ConPTY 1.22+ blocks at spawn awaiting a DA1 reply; answers `CSI ?61;4c` and consumes the query so xterm's default `?1;2c` can't double-reply. */
installConptyPrimaryDeviceAttributesOverride(): void {
// Why idempotent: the spawn mark can land after daemon stream data
// already created the emulator, so the override is installed both at
// creation and retrofitted at mark time — never stacked.
// Why idempotent: installed at creation and again at spawn-mark time (which can land later), so it's never stacked.
if (this.conptyDa1OverrideInstalled) {
return
}
@@ -162,11 +117,7 @@ export class HeadlessEmulator {
})
}
/** Phase-5 slice-2 view-attribute bridge: the headless core has no theme
* service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the
* renderer's pushed attributes via these parser handlers — never from
* emulator defaults. Runtime-only, like onQueryReply: the daemon Session
* must NEVER call this (its emulator stays write-only forever). */
/** Headless core has no theme service, so OSC 4/10/11/12 and DSR ?996n answer from the renderer's pushed attributes; daemon Session must never call this. */
installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void {
if (this.viewAttributeResponder) {
return
@@ -174,18 +125,12 @@ export class HeadlessEmulator {
this.viewAttributeResponder = installTerminalViewAttributeResponder({
parser: this.terminal.parser,
getBaseAttributes,
// emitQueryReply keeps replies inside the per-chunk forwarding window,
// so seeded/replayed view-attribute queries answer no one.
// emitQueryReply keeps replies in the per-chunk forwarding window, so seeded/replayed queries answer no one.
emitReply: (reply) => this.emitQueryReply(reply)
})
}
/** Applies a renderer view-attribute push: cursor options make xterm core
* answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC
* color overrides are dropped because a theme apply overwrites mutated
* colors on visible panes too (ThemeService._setTheme parity). Option
* writes happen outside any forwarding window, so any core emission they
* trigger is discarded (main-side replay guard). */
/** Sets cursor options so xterm answers DECSCUSR / DECRQM 12 renderer-true; per-PTY color overrides are dropped (a theme apply overwrites them anyway). */
applyPushedViewAttributes(attributes: TerminalViewAttributes): void {
if (this.disposed) {
return
@@ -195,13 +140,7 @@ export class HeadlessEmulator {
this.viewAttributeResponder?.clearColorOverrides()
}
/** Re-seed parity for snapshot `modes.kittyKeyboardFlags`
* (terminal-query-authority.md §kitty): replays the persisted flags
* through the same `CSI = flags ; 1 u` parse a live push uses, so hidden
* `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write —
* outside any forwarding window, it can never answer anything — and never
* into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty
* reset stays authoritative). */
/** Re-seeds snapshot kitty flags via the live-push parse, routed unflagged so it can never answer a query (terminal-query-authority.md). */
applyKittyKeyboardFlags(flags: number): Promise<void> {
if (!Number.isInteger(flags) || flags <= 0) {
return Promise.resolve()
@@ -215,9 +154,7 @@ export class HeadlessEmulator {
}
}
/** Severs the reply sink at PTY teardown. Queued writeChain links may
* still parse after dispose is requested, and daemon respawns reuse
* session ids — a late reply must never reach a successor PTY. */
/** Severs the reply sink so a post-dispose reply can't reach a successor PTY (respawns reuse session ids). */
disableQueryReplyForwarding(): void {
this.onQueryReply = null
}
@@ -232,12 +169,7 @@ export class HeadlessEmulator {
return Promise.resolve()
}
this.oscText.scan(data)
// Why the sentinel: xterm parses queued writes asynchronously, so opening
// the window at enqueue time would leak it over earlier queued unflagged
// chunks (seed/hydration bytes parsing while depth > 0). Write callbacks
// fire in FIFO parse order, so a zero-byte write whose callback opens the
// window brackets the parse of exactly this chunk; the data callback
// closes it.
// Why the sentinel: xterm parses writes async, so its zero-byte callback fires in FIFO order to open the window at exactly this chunk.
if (forwardQueryReplies) {
this.terminal.write('', () => {
this.queryReplyForwardingDepth += 1
@@ -248,8 +180,7 @@ export class HeadlessEmulator {
if (forwardQueryReplies) {
this.queryReplyForwardingDepth -= 1
}
// Why: snapshots combine serialized xterm state with mirrored mouse
// modes. Commit the mirror only after xterm has parsed the same bytes.
// Why: commit the mouse-mode mirror only after xterm has parsed the same bytes (snapshots combine both).
this.mouseModes.scan(data)
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
resolve()
@@ -257,10 +188,7 @@ export class HeadlessEmulator {
})
}
/** Synchronous write used by cold-restore log replay, where a snapshot is
* taken immediately after the last record and queued async writes would
* serialize a half-applied stream. Returns false when xterm's synchronous
* write path is unavailable — callers must then abandon the replay. */
/** Synchronous write for cold-restore replay (async would snapshot a half-applied stream); false when writeSync is unavailable. */
writeSync(data: string): boolean {
if (this.disposed) {
return false
@@ -278,8 +206,7 @@ export class HeadlessEmulator {
if (forwardQueryReplies) {
this.queryReplyForwardingDepth += 1
}
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
// Why: restore snapshots are requested right after PTY bursts; queued writes could snapshot half-cleared TUI rows.
try {
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
} finally {
@@ -300,21 +227,14 @@ export class HeadlessEmulator {
this.terminal.resize(cols, rows)
}
// Why: Session.resize applies this emulator and the node-pty subprocess
// together behind the same dead/invalid-size gate, so the emulator's dims are
// an accurate proxy for the size the child actually took — and stay stale
// when a resize is dropped, which is exactly the drop the renderer must detect.
// Why: these dims proxy the child's real size, so they stay stale on a dropped resize the renderer must detect.
getAppliedSize(): { cols: number; rows: number } {
return { cols: this.terminal.cols, rows: this.terminal.rows }
}
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot {
const modes = this.getModes()
// Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor
// restore lands one column short after a margin-filling final row leaves
// replay wrap-pending; the trailing CUP survives the alt-marker slice.
// The saved-cursor register rides along so a post-restore DECRC lands
// where the hidden TUI saved, not at home.
// Why absolute: relative cursor restore is off by a column after a wrap-pending final row; saved-cursor rides along for DECRC.
const serializedAnsi = serializeWithAbsoluteCursor(
this.serializer,
this.terminal,
@@ -337,20 +257,13 @@ export class HeadlessEmulator {
rows: this.terminal.rows,
scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows,
lastTitle: this.oscText.lastTitle ?? undefined,
// Why: written LAST by the restorer (after any reset) so the next live
// chunk completes this dangling sequence instead of rendering it literally
// (Bug E / #7329). Its bytes are already counted by the snapshot seq.
// Why written LAST by the restorer: the next live chunk must complete this dangling sequence, not render it literally (Bug E / #7329).
...(this.partialEscapeTail.length > 0
? { pendingEscapeTailAnsi: this.partialEscapeTail }
: {})
}
if (this.partialEscapeTail.length > 0) {
// Why a separate field, not part of snapshotAnsi: consumers write their
// own reset sequences after the snapshot body, and any ESC written after
// a dangling partial would abort it. The restorer must write this LAST,
// immediately before post-snapshot live chunks. Its bytes are already
// counted by the snapshot seq (they were ingested), so tail-slicing
// arithmetic is unchanged.
// Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it.
snapshot.pendingEscapeTailAnsi = this.partialEscapeTail
}
return snapshot
@@ -360,21 +273,12 @@ export class HeadlessEmulator {
return this.terminal.buffer.active.type === 'alternate'
}
/** The dangling incomplete escape at the current stream position (empty
* when none). Scan-authority handoffs seed the other side's fact scanners
* with it so a sequence split across the handoff neither mints a phantom
* bell (unseen OSC terminator) nor loses its fact. Contains no complete
* sequence by construction, so seeding can never double-fire. */
/** Dangling incomplete escape at the stream position; handoffs seed the other side so a split sequence isn't lost. */
get partialEscapeTailAnsi(): string {
return this.partialEscapeTail
}
/** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
* pending input it re-renders at a cached buffer row that ConPTY's fixed
* viewport doesn't track, painting the input well below the prompt. The
* cursor line counts as an empty prompt when everything before the cursor
* ends with a single '>' and nothing follows it ('>>' is PowerShell's
* continuation prompt, i.e. a multiline edit in flight). */
/** PSReadLine's Ctrl+L repaint is only safe at an empty prompt; '>>' is PowerShell's continuation prompt, not empty. */
isCursorOnEmptyPromptLine(): boolean {
const buffer = this.terminal.buffer.active
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
@@ -6,38 +6,14 @@ import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import { HeadlessEmulator } from './headless-emulator'
// Reproduction for the "blank pane after agent hibernation" bug.
//
// Verified root cause (NOT the meta.endedAt gate — see below):
// Agent hibernation force-kills the agent PTY via the immediate path
// (pty:kill -> shutdown({ immediate: true, keepHistory: true }) ->
// TerminalHost.kill(immediate) -> forceKillAndDisposeSubprocess), which reaps
// synchronously WITHOUT firing onExit. So closeSession never runs and
// meta.endedAt stays null — detectColdRestore does NOT reject the session.
//
// The actual blank comes from cold-restore CONTENT, not eligibility:
// Claude/Codex TUIs run in terminal alternate-screen mode. For an alt-screen
// snapshot, HistoryReader.coldRestoreInfoFromSnapshot returns scrollbackAnsi=''
// (history-reader.ts:190-191), and DaemonPtyAdapter then skips the cold-restore
// payload entirely on `if (scrollback)` (daemon-pty-adapter.ts:230) — "no
// content is better than a confusing empty restore." Result: the daemon sends
// nothing back on wake and the preserved pane repaints blank, even though a
// full snapshotAnsi of the agent's last screen is intact on disk.
//
// This test drives a real HeadlessEmulator into alt-screen mode, checkpoints it
// through the real HistoryManager, and asserts the empty-scrollback outcome the
// adapter treats as "no cold restore".
//
// Scope note: this file documents the bug MECHANISM at the emulator/reader layer
// and replicates the adapter's payload decision inline, so its post-fix
// assertions would still pass if the production line were reverted. The actual
// regression guard that exercises DaemonPtyAdapter.spawn() end-to-end lives in
// daemon-pty-adapter.test.ts ("cold-restores an alt-screen agent snapshot…").
// Reproduces the "blank pane after agent hibernation" bug: alt-screen TUI snapshots have scrollbackAnsi='', so the adapter's
// `if (scrollback)` gate (daemon-pty-adapter.ts:230) dropped the cold-restore payload and repainted blank despite an intact snapshotAnsi.
// Not the meta.endedAt gate: hibernation's immediate kill never fires onExit, so endedAt stays null (session not rejected).
// This test inlines the adapter's decision, so it'd pass even if the fix were reverted; the real end-to-end guard is daemon-pty-adapter.test.ts.
const ALT_SCREEN_ON = '\x1b[?1049h'
// Note: HistoryManager.checkpoint() takes the emulator's TerminalSnapshot directly
// and stamps generation / checkpointedAt itself, so em.getSnapshot() is passed as-is.
// Note: checkpoint() stamps generation/checkpointedAt itself, so em.getSnapshot() is passed as-is.
describe('agent hibernation cold-restore (alt-screen TUI)', () => {
let dir: string
@@ -65,8 +41,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
expect(info).not.toBeNull()
// Adapter uses rehydrateSequences + snapshotAnsi for non-alt-screen → non-empty.
expect(info!.modes.alternateScreen).toBe(false)
// Normal-screen restores carry their buffer as scrollback; assert both so a
// regression that empties scrollbackAnsi can't slip past this control.
// Normal-screen restores carry their buffer as scrollback; assert both so a regression emptying scrollbackAnsi can't slip past.
expect(info!.scrollbackAnsi).toContain('hello')
expect(info!.snapshotAnsi).toContain('hello')
})
@@ -75,8 +50,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
// Why: Claude/Codex enter the alternate screen. Once in alt-screen, the
// serialized snapshot is the TUI buffer and scrollbackAnsi is empty.
// Why: in alt-screen (Claude/Codex TUIs) the serialized snapshot is the TUI buffer and scrollbackAnsi is empty.
em.writeSync(ALT_SCREEN_ON)
em.writeSync('\x1b[2J\x1b[H Claude Code — Opus 4.8\r\n > ')
expect(em.isAlternateScreen).toBe(true)
@@ -86,20 +60,15 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
em.dispose()
const info = reader.detectColdRestore(sessionId)
// The session IS eligible (endedAt is null — hibernation's immediate kill
// never stamps it), and the snapshot of the agent's screen is intact...
// Session is eligible (endedAt null) and the agent's snapshot is intact.
expect(info).not.toBeNull()
expect(info!.modes.alternateScreen).toBe(true)
expect(info!.snapshotAnsi.length).toBeGreaterThan(0)
// scrollbackAnsi is empty for alt-screen (the bug's trigger). Pre-fix the
// adapter's `isAltScreen ? scrollbackAnsi || null : ...` dropped the
// payload here, leaving the pane blank.
// scrollbackAnsi is empty for alt-screen the bug's trigger; pre-fix the adapter dropped the payload here, leaving the pane blank.
expect(info!.scrollbackAnsi).toBe('')
// Replicate the adapter's POST-FIX payload decision: alt-screen falls
// back to snapshotAnsi (the agent's last frame) when scrollbackAnsi is
// empty, so the pane is no longer blank on wake.
// Replicate the adapter's post-fix decision: alt-screen falls back to snapshotAnsi when scrollbackAnsi is empty, so the pane isn't blank.
const isAltScreen = info!.modes.alternateScreen
const adapterScrollback = isAltScreen
? info!.scrollbackAnsi || info!.snapshotAnsi || null
@@ -125,13 +94,8 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
: info!.rehydrateSequences + info!.snapshotAnsi
expect(adapterScrollback).not.toBeNull()
// Drive the renderer's cold-restore branch into a FRESH shell emulator:
// clear, write the payload, then POST_REPLAY_MODE_RESET. The pane must end
// in the normal buffer (no \x1b[?1049h fed) so it won't fight the agent's
// own repaint when the resume command relaunches it.
// Keep in sync with POST_REPLAY_MODE_RESET in
// src/renderer/src/components/terminal-pane/layout-serialization.ts (copied
// as a literal because a main-process test must not import a renderer module).
// Must end in the normal buffer (no alt-screen re-entry) so it won't fight the agent's own repaint when resume relaunches it.
// POST_REPLAY_MODE_RESET copied literally from renderer layout-serialization.ts (main-process test can't import renderer) — keep in sync.
const POST_REPLAY_MODE_RESET =
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l'
const fresh = new HeadlessEmulator({ cols: 80, rows: 24 })
@@ -147,10 +111,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
const manager = new HistoryManager(dir)
const reader = new HistoryReader(dir)
const em = new HeadlessEmulator({ cols: 80, rows: 24 })
// Alt-screen entered but nothing drawn. SerializeAddon still emits a bare
// cursor-home (\x1b[H), so the payload is non-null but visually empty —
// safe to write into the fresh shell and crucially never re-enters
// alt-screen (rehydrateSequences is omitted).
// Alt-screen but nothing drawn: SerializeAddon emits only a bare cursor-home, so the payload never re-enters alt-screen.
em.writeSync(ALT_SCREEN_ON)
await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 })
+20 -68
View File
@@ -21,10 +21,7 @@ import {
} from './terminal-history-log'
import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types'
// Why 5MB: bounds both cold-restore replay time and disk usage per session.
// Reaching the cap triggers one full snapshot checkpoint (which subsumes and
// resets the log) — one O(buffer) serialize per ~5MB of output instead of one
// per 5-second tick.
// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log.
const LOG_MAX_BYTES = 5 * 1024 * 1024
export type SessionMeta = {
@@ -46,8 +43,7 @@ type SessionWriter = {
dir: string
checkpointPath: string
logPath: string
/** Generation of the on-disk log header. Null until lazily resolved on the
* first append after a warm registerWriter (the file may predate us). */
/** Generation of the on-disk log header. Null until lazily resolved on first append after a warm registerWriter. */
logGeneration: number | null
/** Current log file size. Null until lazily resolved alongside generation. */
logBytes: number | null
@@ -84,13 +80,7 @@ export class HistoryManager {
}
writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2))
// Why: if a session ID is reused after a previous clean exit, stale
// recovery files may still be on disk. Without removing them, a crash
// before the first 5s checkpoint tick would cause detectColdRestore to
// replay stale terminal content from the previous session. Both
// checkpoint.json and scrollback.bin (legacy) must be cleaned up
// because the reader falls back to scrollback.bin when no checkpoint
// exists.
// Why: clear stale recovery files (incl. legacy scrollback.bin) so a crash before the first checkpoint can't replay a prior session's content.
const checkpointPath = join(dir, 'checkpoint.json')
const logPath = join(dir, 'output.log')
for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin'), logPath]) {
@@ -113,11 +103,7 @@ export class HistoryManager {
}
}
// Why: on warm reattach after app relaunch, the HistoryManager is a fresh
// instance with no in-memory writers. This registers the writer so
// checkpoint() calls work, without overwriting meta.json or deleting the
// existing checkpoint.json (which is the only valid recovery data until
// the next checkpoint tick writes a fresh one).
// Why: warm reattach has no in-memory writers; re-register without touching meta.json or checkpoint.json (only recovery data until the next tick).
registerWriter(sessionId: string): void {
if (this.writers.has(sessionId)) {
return
@@ -132,10 +118,7 @@ export class HistoryManager {
})
}
// Why: wake after sleep re-spawns a session whose history was closed by the
// sleep-time kill. Re-register the writer without deleting checkpoint.json
// (still the only recovery data until the next tick) and clear endedAt so
// the next sleep can cold-restore this session again.
// Why: wake re-spawns a sleep-killed session; re-register without deleting checkpoint.json, clear endedAt so it can cold-restore again.
reopenSession(sessionId: string): void {
this.disabledSessions.delete(sessionId)
this.registerWriter(sessionId)
@@ -151,16 +134,12 @@ export class HistoryManager {
}
suspendSession(sessionId: string): void {
// Why: if a fresh daemon cannot accept recovered scrollback, leaving its
// writer active would let the next checkpoint overwrite the only good copy.
// Why: leaving the writer active would let the next checkpoint overwrite the only good recovered-scrollback copy.
this.writers.delete(sessionId)
this.disabledSessions.delete(sessionId)
}
/** Appends one take batch to the incremental log. Returns 'needs-checkpoint'
* when the log is at capacity — the caller must take a full snapshot, which
* subsumes the un-appended records (they were already applied to the live
* emulator) and resets the log via checkpoint(). */
/** Appends one batch to the incremental log; returns 'needs-checkpoint' at capacity, signalling the caller to checkpoint() (which resets the log). */
async appendIncrements(
sessionId: string,
seq: number,
@@ -176,16 +155,13 @@ export class HistoryManager {
try {
this.resolveLogState(writer)
const batch = encodeLogBatch(seq, records)
// Why max(..., header): a fresh log gets its header written below, so
// the projected size must include it or the cap can be overshot.
// Why max(..., header): a fresh log's header (written below) must count toward the projected size or the cap overshoots.
const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length
if (projectedBytes > LOG_MAX_BYTES) {
return 'needs-checkpoint'
}
if (writer.logBytes === 0) {
// Why: header carries the generation that ties this log to its base
// checkpoint; written lazily so warm reattaches never clobber a log
// that already has appended batches.
// Why: header ties this log to its base checkpoint; written lazily so warm reattaches don't clobber an appended log.
await fsPromises.writeFile(writer.logPath, encodeLogHeader(writer.logGeneration ?? 0))
writer.logBytes = LOG_HEADER_BYTES
}
@@ -198,9 +174,7 @@ export class HistoryManager {
}
}
// Why: replaces the old appendData (which wrote every PTY chunk to disk).
// Full checkpoints are now rare (clean disconnect, pending-buffer overflow,
// log cap); the 5s tick appends increments via appendIncrements instead.
// Full checkpoints are rare (clean disconnect, pending-buffer overflow, log cap); the 5s tick appends increments instead.
async checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<void> {
if (this.disabledSessions.has(sessionId)) {
return
@@ -211,10 +185,7 @@ export class HistoryManager {
}
try {
// Why: shells that haven't emitted OSC-7 have snapshot.cwd = null.
// Persisting null would overwrite the usable cwd from meta.json,
// breaking cold restore cwd recovery. Fall back to the meta cwd
// so the revived shell inherits the original working directory.
// Why: snapshot.cwd is null until OSC-7; persisting null would clobber meta.json's usable cwd and break cold-restore recovery.
let effectiveCwd = snapshot.cwd
if (effectiveCwd === null) {
const meta = this.readMetaFromDir(writer.dir)
@@ -237,19 +208,12 @@ export class HistoryManager {
checkpointedAt: new Date().toISOString()
}
const data = JSON.stringify(checkpointFile)
// Why: atomic write via tmp+rename prevents half-written checkpoints
// on crash. Reading a corrupt checkpoint is worse than reading a
// slightly stale one. Async IO — a sync ~MB write (worse under
// antivirus scanning on Windows) would stall input/IPC for its
// duration. Overlap is prevented by the adapter's checkpointInFlight
// guard, which awaits this promise before the next tick.
// Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV).
// The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path.
const tmpPath = `${writer.checkpointPath}.tmp`
await fsPromises.writeFile(tmpPath, data)
await fsPromises.rename(tmpPath, writer.checkpointPath)
// Why: the snapshot subsumes every logged record, so the log resets to
// the new generation. Crash between rename and this reset is safe: the
// stale log's generation no longer matches the checkpoint's, so the
// restore reader ignores it.
// Why: snapshot subsumes logged records, so reset the log to the new generation; a stale-generation log is ignored on restore.
await fsPromises.writeFile(writer.logPath, encodeLogHeader(generation))
writer.logGeneration = generation
writer.logBytes = LOG_HEADER_BYTES
@@ -258,10 +222,7 @@ export class HistoryManager {
}
}
// Why: a warm registerWriter may attach to a session dir that already has a
// log (app relaunch while the daemon kept running). Generation and size are
// read from disk once so appends continue the existing stream instead of
// clobbering it.
// Why: a warm registerWriter may attach to an existing log; read generation/size once so appends continue it, not clobber it.
private resolveLogState(writer: SessionWriter): void {
if (writer.logBytes !== null && writer.logGeneration !== null) {
return
@@ -287,9 +248,7 @@ export class HistoryManager {
writer.logBytes = size
return
}
// Missing or unreadable header: logBytes = 0 makes the next append rewrite
// the file from scratch (writeFile truncates), so a garbage file cannot be
// extended.
// Missing/unreadable header: logBytes = 0 makes the next append truncate-rewrite, so a garbage file can't be extended.
writer.logBytes = 0
writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0
}
@@ -310,16 +269,12 @@ export class HistoryManager {
}
this.writers.delete(sessionId)
// Why: the session is dead, so its disabled flag is dead state. Without this
// a session poisoned by a transient mid-life write error leaks its id in
// disabledSessions forever (sessionIds are fresh per PTY, never reused).
// Why: session is dead; without this a transient-error-poisoned id leaks forever (sessionIds never reused).
this.disabledSessions.delete(sessionId)
try {
this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode })
} catch (err) {
// Why: if endedAt can't be written, the session looks like an unclean
// shutdown and triggers a false cold restore on next launch. Disable
// further writes and report, but don't crash the app.
// Why: an unwritten endedAt looks like an unclean shutdown → false cold restore next launch.
this.handleWriteError(sessionId, err)
}
}
@@ -358,8 +313,7 @@ export class HistoryManager {
}
async dispose(): Promise<void> {
// Why: mark all open sessions as cleanly ended so they don't trigger
// false cold-restores on next launch.
// Why: mark open sessions cleanly ended so they don't trigger false cold-restores next launch.
for (const [sessionId, writer] of this.writers) {
try {
this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode: null })
@@ -370,9 +324,7 @@ export class HistoryManager {
this.writers.clear()
}
// Why: history is best-effort — any error should disable the session
// rather than crash the app. Callers use fire-and-forget `void` promises,
// so a re-thrown error would become an unhandled rejection.
// Why: history is best-effort; callers fire-and-forget so a throw would be an unhandled rejection — disable instead.
private handleWriteError(sessionId: string, err: unknown): void {
this.disabledSessions.add(sessionId)
this.onWriteError?.(sessionId, err as Error)
+55 -168
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: daemon PTY spawning centralizes platform launch setup,
preflight validation, and lifecycle guards that must stay in one execution path. */
/* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */
import * as pty from 'node-pty'
import { statSync } from 'node:fs'
import { delimiter, win32 as pathWin32 } from 'node:path'
@@ -74,18 +73,12 @@ const PANE_IDENTITY_ENV_KEYS = [
] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
// Why: a Windows refresh forks a powershell.exe whole-process-table CIM scan
// (~10-40x heavier than POSIX `ps`). An idle shell with no agent identity and
// no recent output retries far slower; output re-arms the 5s retry so an agent
// start (which always prints) is still resolved promptly.
// Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry.
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
// Why: a busy machine right after an upgrade can make one short-lived shell
// spawn slow. Retry once before declaring the daemon unable to spawn PTYs, so
// a transient stall does not silently route every fresh terminal to the local
// fallback (losing daemon persistence) until a manual restart.
// Why: retry once so a transient slow spawn doesn't route every terminal to the local fallback, losing daemon persistence.
const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
@@ -99,9 +92,7 @@ function composeGuardedDaemonGitConfigEnv(
if (policy !== 'guard' && launchAgent === undefined) {
return
}
// Why: the daemon can outlive Electron, so only its process.env is the
// authoritative inherited config. The raw env merge already gives an
// explicit wire protocol normal override semantics; append only the guard.
// Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard.
Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform))
}
@@ -170,9 +161,7 @@ function removeInheritedDevAgentHookEndpoint(
explicitEnv: Record<string, string> | undefined
): void {
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) {
// Why: the daemon inherits the app process env before per-PTY env is
// merged. Strip only stale parent endpoints; a fresh explicit endpoint is
// needed by hooks whose runners scrub token-like env vars before exec.
// Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec.
delete env.ORCA_AGENT_HOOK_ENDPOINT
}
}
@@ -181,8 +170,7 @@ function removeInheritedDevAgentHookEndpoint(
* Strips Electron's internal run-as-node flag from user shell environments.
*/
function removeInheritedElectronRunAsNode(env: Record<string, string>): void {
// Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells
// must not inherit it or nested Electron commands run as plain Node.
// Why: user shells must not inherit ELECTRON_RUN_AS_NODE or nested Electron commands run as plain Node.
delete env.ELECTRON_RUN_AS_NODE
}
@@ -251,9 +239,7 @@ function preflightDaemonCwd(): void {
// Recover below; process.cwd() throws after the original cwd is deleted.
}
// Why: older detached daemons were launched from the repo cwd. If that
// worktree disappears, node-pty's macOS spawn-helper can fail even when the
// requested terminal cwd is valid.
// Why: if the daemon's launch worktree disappears, node-pty's macOS spawn-helper fails even when the terminal cwd is valid.
if (repairDaemonCwd()) {
return
}
@@ -296,8 +282,7 @@ function preflightUnixPtySpawnEnvironment(): void {
return
}
// Why: detached daemons can outlive their launch cwd; repair before every
// PTY spawn so Linux/macOS do not wait for startup health recovery.
// Why: detached daemons can outlive their launch cwd; repair before every spawn so Linux/macOS don't wait for health recovery.
preflightDaemonCwd()
preflightMacNodePtySpawnEnvironment()
}
@@ -404,8 +389,7 @@ function runSinglePtySpawnHealthProbe(): Promise<void> {
})
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
// Why: ping only proves the daemon protocol is alive. A real short-lived
// PTY spawn catches stale node-pty helper paths captured by this process.
// Why: ping only proves the protocol is alive; a real spawn catches stale node-pty helper paths.
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
@@ -426,9 +410,7 @@ export async function checkPtySpawnHealth(): Promise<void> {
return
}
// Why: Linux/macOS daemons can outlive an app update with a deleted cwd or
// stale native PTY path. A real short-lived spawn catches that before the
// main process routes fresh panes to a daemon that cannot create terminals.
// Why: a real short-lived spawn catches a deleted cwd or stale native PTY path before panes are routed to a daemon that can't spawn.
if (process.platform === 'darwin') {
ensureNodePtySpawnHelperExecutable()
}
@@ -474,8 +456,7 @@ function resolveFallbackForegroundProcess(
if (normalized || process.platform !== 'win32') {
return normalized
}
// Why: Windows node-pty can report the terminal name instead of the shell.
// Use the spawned shell so shell-rooted foreground enrichment still runs.
// Why: Windows node-pty can report the terminal name instead of the shell; fall back to the spawned shell.
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
@@ -483,10 +464,7 @@ function resolveFallbackForegroundProcess(
* Spawns the daemon PTY, walking the Windows PowerShell -> cmd.exe fallback
* chain when ConPTY rejects the primary shell with ERROR_ACCESS_DENIED.
*
* Why: the daemon spawns node-pty directly (no LocalPtyProvider), so it needs
* its own chain walk. The first attempt must match the already-resolved
* shellPath/shellArgs/spawnCwd; later attempts carry their own recomputed args
* so the cmd.exe fallback still gets `chcp 65001`.
* Why: the daemon has no LocalPtyProvider, so it owns its chain walk; later attempts recompute args so the cmd.exe fallback still gets `chcp 65001`.
*/
function spawnDaemonPtyWithWindowsFallback(args: {
shellPath: string
@@ -510,8 +488,7 @@ function spawnDaemonPtyWithWindowsFallback(args: {
rows: args.rows,
cwd,
env: args.env,
// Why: bundled ConPTY has the modern wrap-marker behavior xterm expects;
// legacy system ConPTY can corrupt full-width TUI rows in scrollback.
// Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects.
...(process.platform === 'win32' ? { useConptyDll: true } : {})
})
}
@@ -561,17 +538,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION. The daemon is forked
// by main (daemon-init.ts:93) with the parent's env, so ORCA_APP_VERSION
// — set in src/main/index.ts from app.getVersion() — is inherited here.
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; ORCA_APP_VERSION is inherited from the forking main process.
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
// Why: opt tools (Claude Code, ls --hyperlink, etc.) into emitting OSC 8
// hyperlinks. The `supports-hyperlinks` npm package gates on a hard-coded
// TERM_PROGRAM allowlist (iTerm.app / WezTerm / vscode) and returns false
// for TERM_PROGRAM=Orca, so callers drop OSC 8 output entirely and emit
// bare text instead. xterm.js in Orca parses OSC 8 and the pane's
// linkHandler routes clicks, so forcing the advertisement is safe and
// restores clickable refs like `owner/repo#123` / `PR#123`.
// Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links.
FORCE_HYPERLINK: '1'
} as Record<string, string>
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
@@ -581,8 +550,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: the daemon is forked from Electron and can inherit the pane identity
// of the terminal that launched `pn dev`; each PTY must opt into its own.
// Why: the daemon can inherit the pane identity of the terminal that launched `pn dev`; each PTY must opt into its own.
removeUnspecifiedPaneIdentityEnv(env, opts.env)
removeInheritedDevAgentHookEndpoint(env, opts.env)
removeInheritedElectronRunAsNode(env)
@@ -591,14 +559,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
env.LANG ??= 'en_US.UTF-8'
// Why: the shellOverride from the "+" menu (or persisted default shell
// setting, relayed by main) takes priority over env.COMSPEC — otherwise
// Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback,
// no matter which shell the user actually picked.
// Why: shellOverride must win over env.COMSPEC, or Windows always resolves to cmd.exe/PowerShell regardless of the user's pick.
const resolvedWslContext = resolveWslSessionContext(opts)
// Why: WSL worktree cwd is the repo's execution environment. Older persisted
// tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and
// daemon-backed terminals enter the WSL distro just like LocalPtyProvider.
// Why: older persisted tabs can carry a PowerShell/cmd shellOverride; ignore it so WSL reconnects still enter the distro.
let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
let shellArgs: string[]
let startupCommandDeliveredInShellArgs = false
@@ -615,11 +578,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
if (process.platform === 'win32') {
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath)
// Why: daemon spawn requests can carry either a canonical shell family
// (`powershell.exe`) or a concrete PowerShell executable path from a
// one-off override. Normalize both forms back to the PowerShell family so
// the shared resolver can still fall back to inbox powershell.exe when
// pwsh.exe was requested but is unavailable.
// Why: normalize concrete PowerShell paths to the family so the resolver can fall back to powershell.exe when pwsh.exe is unavailable.
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
? normalizedShellFamily
@@ -646,11 +605,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}) ?? shellPath)
: shellPath
}
// Why: when the selected shell is a PowerShell family, resolve it to a real
// absolute executable and build a PowerShell -> cmd.exe fallback chain. A
// bare `pwsh.exe` lets ConPTY resolve the Store App Execution Alias stub,
// whose CreateProcessW launch fails with ERROR_ACCESS_DENIED (error code 5).
// Mirrors LocalPtyProvider so daemon-backed terminals keep arg parity.
// Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5).
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd: spawnCwd,
@@ -679,8 +634,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
if (isWindowsGitBashShellPath(shellPath)) {
// Why: Git for Windows login startup files otherwise cd to $HOME,
// ignoring node-pty's cwd for repo-scoped terminals.
// Why: Git for Windows login startup files otherwise cd to $HOME, ignoring node-pty's cwd.
env.CHERE_INVOKING ??= '1'
}
const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null
@@ -713,27 +667,22 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
}
} else if (isHostCodexHomeForWsl(env.CODEX_HOME)) {
// Why: Orca's selected Codex runtime home is host-local. WSL Codex
// must use its Linux-side ~/.codex instead of a Windows path.
// Why: host-local Codex home is unusable in WSL; let WSL Codex use its Linux-side ~/.codex.
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 accounts pass a Linux CLAUDE_CONFIG_DIR
// through Windows wsl.exe; non-default env vars need WSLENV import.
// Why: non-default env vars need WSLENV import to cross Windows wsl.exe into the Linux side.
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
// Why: the startup wrapper expands this only inside WSL; wsl.exe
// otherwise drops custom Windows environment variables.
// Why: wsl.exe drops custom Windows env vars unless named in WSLENV.
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
// CODEX_HOME from it after user profiles run.
// Why: WSL Codex homes are Linux paths; also drop ORCA_CODEX_HOME since shell-ready restores CODEX_HOME from it.
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
}
@@ -741,17 +690,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
addOrcaWslInteropEnv(env)
}
} else {
// Why: relay-side launch modes can ask for host defaults to stay scrubbed
// even after environment normalization above.
// Why: relay-side launch modes can ask for host defaults to stay scrubbed even after normalization above.
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why after the scrub: SHELL must reflect the shell that actually spawns,
// matching LocalPtyProvider; and before the launch-config derivation below
// so shell-ready wrappers target the resolved shell, not a missing one.
// Why: set SHELL after the scrub and before launch-config derivation so shell-ready wrappers target the resolved shell.
const preferredShellPath = shellPath
shellPath = resolveUnixShellPath(shellPath)
if (shellPath !== preferredShellPath) {
@@ -760,16 +706,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
)
}
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
// Why: OpenCode/Codex path restoration and OMP's typed-command status wrapper need shell-ready code after user startup files run.
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
if (opts.command && isCodexStartupCommand) {
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
})
// Why: payload-bearing Codex startup text can be dropped by rc-file noise;
// plain Codex stays markerless to preserve the startup-speed path.
// Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path.
shellLaunch = shouldWaitForShellReady
? getShellReadyLaunchConfig(shellPath)
: getAttributionShellLaunchConfig(shellPath)
@@ -801,9 +745,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
promoteAgentTeamsShimPath(env, opts.env?.PATH)
// Why: asar packaging can strip the +x bit from node-pty's spawn-helper
// binary. The main process fixes this via LocalPtyProvider, but the daemon
// runs in a separate forked process with its own code path.
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
ensureNodePtySpawnHelperExecutable()
preflightUnixPtySpawnEnvironment()
preflightPosixPtySpawnEnvironment(validationCwd)
@@ -824,8 +766,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
windowsFallbackAttempts
})
proc = spawned.process
// Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded
// startup command, so adopt the winning shell's identity + delivery flag.
// Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded startup command; adopt the winning shell's identity + delivery flag.
shellPath = spawned.shellPath
spawnCwd = spawned.spawnCwd
if (spawned.startupCommandDeliveredInShellArgs !== undefined) {
@@ -845,8 +786,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let pendingPreListenerExitCode: number | null = null
const bufferPreListenerData = (data: string): void => {
// Why: Windows shell-arg startup commands can print before Session wires
// this subprocess into the daemon. Preserve that spawn-time race window.
// Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window.
pendingPreListenerData.push(data)
pendingPreListenerDataChars += data.length
while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) {
@@ -891,11 +831,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
})
// Why: node-pty's native NAPI layer throws a C++ Napi::Error when
// write/resize/kill is called on a PTY whose underlying fd is already
// closed. This happens in the race window between the child process
// exiting and the JS onExit callback firing. An uncaught Napi::Error
// propagates to std::terminate, killing the entire daemon process.
// Why: node-pty throws Napi::Error if write/resize/kill hit a closed fd (child-exit vs onExit race); uncaught it std::terminates the daemon.
let dead = false
let disposed = false
let nodePtyKillIssued = false
@@ -931,8 +867,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
// Why: agent-spawned helper processes can become the PTY foreground
// child; the Unix process tree can still identify the parent agent.
// Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent.
process.platform !== 'win32')
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || !proc.pid) {
@@ -949,9 +884,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
const now = Date.now()
const idleNoEvidenceShell =
fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground
// Why: on Windows each refresh is a whole-table CIM scan; only shells with
// no agent evidence and no recent output relax, so agent-identity refresh
// (cached identity → 1s TTL) and post-output starts keep the fast retry.
// Why: on Windows each refresh is a whole-table CIM scan, so only shells with no agent evidence and no recent output relax the retry.
const retryMs = !idleNoEvidenceShell
? FOREGROUND_AGENT_CACHE_TTL_MS
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
@@ -962,11 +895,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon foreground reads are sync and run on the IPC hot path.
// Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.)
// in the background and serve them from a short cache on later reads.
// Why: Windows may need an async membership check before this shared
// shell/wrapper retirement policy is safe to apply.
// Why: daemon foreground reads are sync on the IPC hot path; refresh derived identities in the background and serve from a short cache.
// Why: Windows may need an async membership check before this shell/wrapper retirement policy is safe.
const retireStaleForegroundIdentity = (): void => {
const currentFallbackProcess = getFallbackForegroundProcess()
if (
@@ -983,9 +913,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
// Why: the wrapper's tree no longer resolves to an agent — an expired
// identity must not transfer to an unrelated wrapper (e.g. npm right
// after an agent exit). Fresh identities survive one-off scan hiccups.
// Why: an expired wrapper identity must not transfer to an unrelated wrapper (e.g. npm after an agent exit); fresh ones survive scan hiccups.
cachedAgentForeground = null
}
}
@@ -996,14 +924,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
if (dead) {
return
}
// Why: a degraded scan is not exit evidence; retiring here fires false
// completion while an agent is still working under CIM load.
// Why: a degraded scan isn't exit evidence retiring would fire false completion while the agent works under CIM load.
if (!available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
// Why: a Windows snapshot can omit a live agent; only verified
// shell-only membership may retire its cached identity.
// Why: a Windows snapshot can omit a live agent; only verified shell-only membership may retire cached identity.
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => {
if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) {
@@ -1030,15 +956,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
dead = true
cachedAgentForeground = null
startupAgentForeground = null
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). After the child exits, the master socket's
// 'close' event can fire before our dispose() path gets to neutralize
// proc.kill — the child's pid may have already been recycled, so SIGHUP
// lands on an unrelated process. Neutralizing here, synchronously inside
// the onExit callback, closes that window: once the child is reaped,
// proc.kill is a no-op no matter which teardown ordering wins.
// Windows is excluded because WindowsTerminal.destroy relies on kill() to
// close the ConPTY agent — neutralizing would leak the agent + fds.
// Why: neutralize proc.kill synchronously in onExit so UnixTerminal's async socket-close SIGHUP can't land on a recycled pid.
// Windows excluded: WindowsTerminal.destroy needs kill() to close the ConPTY agent.
if (process.platform !== 'win32') {
;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {}
}
@@ -1049,10 +968,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
shellPath,
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
// Why: node-pty's `.process` getter reports the PTY's live foreground
// process name (the agent running in the shell, or the shell itself) and
// updates as it changes. Null once the child is gone — `.process` on a
// reaped pty can read a recycled pid.
// Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead.
if (dead) {
return null
}
@@ -1071,14 +987,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
) {
return cachedAgentForeground.processName
}
// Why: a wrapper foreground (node/python) can never identify itself, and
// readers poll slower than the cache TTL — returning the raw fallback here
// would hide the resolved identity forever. Serve the last resolved agent
// while the scheduled refresh revalidates. On Windows a shell fallback is
// also an unreliable exit signal (ConPTY lag under load surfaces the shell
// while the agent is alive), so trust the cache there too; the background
// refresh retires the identity only after a console-presence read confirms
// the agent left the console.
// Why: a wrapper (node/python) can't self-identify and readers poll slower than the cache TTL, so serve the last resolved agent.
// On Windows a shell fallback is an unreliable exit signal under ConPTY lag; trust the cache until a console-presence read confirms exit.
if (
cachedAgentForeground &&
fallbackProcess !== null &&
@@ -1109,8 +1019,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
) {
return fallbackProcess
}
// Why: cached/in-flight scans may predate the OSC command boundary.
// Confirmation requires one shared process snapshot started afterward.
// Why: cached/in-flight scans may predate the OSC command boundary; confirmation needs a snapshot started afterward.
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
@@ -1137,8 +1046,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
startupAgentForeground = null
return recognized.processName
}
// Why: a successful post-boundary scan that resolves no agent is the
// authority that retires stale cached/startup identity.
// Why: a post-boundary scan resolving no agent is the authority that retires stale cached/startup identity.
cachedAgentForeground = null
startupAgentForeground = null
return resolution.processName
@@ -1169,10 +1077,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
dead = true
}
},
// Why pause/resume work on Windows too: node-pty's base Terminal
// implements both as socket pause/resume (lib/terminal.js), and
// WindowsTerminal wires _socket to the ConPTY conout pipe — pausing stops
// conout reads so ConPTY's bounded buffer backpressures the child.
// Why pause/resume work on Windows too: WindowsTerminal wires _socket to the ConPTY conout pipe, so pausing backpressures the child.
pause: () => {
if (dead) {
return
@@ -1211,19 +1116,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
try {
proc.kill()
} catch (error) {
// Why: a rejected native kill is not proof of exit. Keep the wrapper
// live and let Session retain/retry the physical owner.
// Why: a rejected native kill isn't proof of exit — keep the wrapper live so Session can retry the owner.
nodePtyKillIssued = false
throw error
}
},
forceKill: () => {
// Why: once the child has been reaped (dead=true via onExit) or dispose
// has run, proc.pid refers to a recycled pid. Sending SIGKILL would
// terminate an unrelated process. The fd release is handled by
// dispose()/destroy(); forceKill is strictly for signalling a live child.
// Why: Windows node-pty kill already closes ConPTY; retrying it through
// forceKill can double-close the native handle during workspace teardown.
// Why: after reap/dispose proc.pid is a recycled pid, so SIGKILL would hit an unrelated process (forceKill only signals a live child).
// Why: Windows node-pty kill already closed ConPTY; forcing again can double-close the native handle.
if (dead || (process.platform === 'win32' && nodePtyKillIssued)) {
return
}
@@ -1243,8 +1143,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
},
signal: (sig) => {
// Why: same recycled-pid hazard as forceKill. Once dead, silently drop
// the signal rather than risk sending it to an unrelated process.
// Why: same recycled-pid hazard as forceKill — once dead, dropping avoids signalling an unrelated process.
if (dead) {
return
}
@@ -1278,24 +1177,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
pendingPreListenerData = []
pendingPreListenerDataChars = 0
pendingPreListenerExitCode = null
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). The socket close fires asynchronously; by then
// the child may have exited and its pid been recycled to an unrelated
// process. Without this neutralization, SIGHUP can be delivered to a
// Chrome tab, editor, or other user process — silent cross-app corruption.
// `_socket.destroy()` still releases the fd; only the dangerous SIGHUP is
// removed.
//
// Platform guard: WindowsTerminal.destroy implements the ConPTY close by
// CALLING `this.kill()` via `_deferNoArgs` (windowsTerminal.js:141-146).
// Neutralizing kill on Windows turns destroy() into a no-op and leaks the
// ConPTY agent. The SIGHUP hazard is POSIX-only, so the guard is too.
// Why: UnixTerminal.destroy()'s async socket-close SIGHUP can land on a recycled pid, hitting an unrelated user process; neutralize kill on POSIX.
// Windows destroy() uses kill() to close the ConPTY agent, so the guard is POSIX-only.
if (process.platform !== 'win32') {
;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {}
} else if (nodePtyKillIssued) {
// Why: WindowsTerminal.destroy() calls kill() internally. If this
// daemon handle already used node-pty's kill(), destroying here can
// close the same ConPTY handle twice and trip Windows heap corruption.
// Why: WindowsTerminal.destroy() calls kill(); destroying after node-pty's kill() double-closes the ConPTY handle (heap corruption).
return
}
try {
+62 -144
View File
@@ -26,26 +26,17 @@ import type {
} from './types'
const SHELL_READY_TIMEOUT_MS = 15_000
// Why: Codex startup skips marker-gated command delivery; this only bounds
// older daemon/local paths that still report shell-ready support for Codex.
// Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex.
export const CODEX_SHELL_READY_TIMEOUT_MS = 300
const KILL_TIMEOUT_MS = 5_000
export const IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS = 8_000
export const SESSION_FORCE_KILL_RETRY_MS = 250
const SESSION_FORCE_KILL_MAX_ATTEMPTS = 2
// Why: pending records exist so the 5s checkpoint can persist increments
// instead of re-serializing the whole buffer. If no client drains them (main
// process gone, history disabled), memory must stay bounded — past the cap we
// drop the records and flag overflow so the next take falls back to one full
// snapshot, which subsumes everything dropped.
// Counted in UTF-16 code units (string .length), which tracks JS heap cost.
// Worst-case wire size for a full take is ~6x this (each control char
// JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB).
// Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag
// overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB).
const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
// Why: producer pause is requested over a fire-and-forget notification, so the
// matching resume can be lost (main crash, dropped socket). A lost resume must
// never wedge a shell: auto-resume after this window; a still-flooded main
// re-asserts the pause on its next watermark check.
// Why: pause is a fire-and-forget notify, so a resume can be lost (main crash, dropped socket); a lost
// resume must never wedge a shell, so auto-resume after this window — a still-flooded main re-pauses.
export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000
export type SubprocessHandle = {
@@ -55,32 +46,26 @@ export type SubprocessHandle = {
getForegroundProcess(): string | null
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
/** True when shell launch args already delivered the startup command, so the
* terminal host must skip its stdin fallback write. */
/** True when shell launch args already delivered the startup command, so the host skips its stdin fallback write. */
startupCommandDeliveredInShellArgs?: boolean
/** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The
* host reconciles the caller's shell-ready assumption against it so a
* fallback shell without a ready marker never gates startup commands. */
/** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready
* assumption against it so a fallback shell without a ready marker never gates startup commands. */
shellPath?: string
write(data: string): void
resize(cols: number, rows: number): void
/** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer
* fills and a flooding child blocks on write. Optional: handles that
* cannot pause simply omit it and flow control degrades to a no-op. */
/** Stop reading the PTY fd (node-pty pause()) so a flooding child blocks on write. Optional:
* handles that cannot pause omit it and flow control degrades to a no-op. */
pause?(): void
resume?(): void
/** Resync the native PTY's own screen state after a frontend clear.
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
* the next prompt repaint land below a blank gap. */
/** Resync the native PTY's screen state after a frontend clear. No-op except on Windows/ConPTY,
* where a stale cursor row makes the next prompt repaint below a blank gap. */
clear?(): void
kill(): void
forceKill(): void
signal(sig: string): void
onData(cb: (data: string) => void): void
onExit(cb: (code: number) => void): void
/** Release the native PTY handle via node-pty's own destroy() path.
* Idempotent. Safe to call after exit. Called by Session on every teardown
* path (natural exit, kill, force-kill, native throw, session dispose). */
/** Release the native PTY handle via node-pty's destroy(). Idempotent; safe to call after exit. */
dispose(): void
}
@@ -96,11 +81,8 @@ export type SessionOptions = {
historySeed?: string
scrollback?: number
wslDistro?: string
// Why: fired once the session reaches a terminal state (natural exit or
// kill-timeout force-dispose) so the owner (TerminalHost) can reap it —
// dispose the headless emulator and drop it from its session map. Without a
// reaper, dead sessions (and their ~5000-row scrollback emulators) accumulate
// for the lifetime of the long-lived daemon process.
// Fired once the session reaches a terminal state so the owner (TerminalHost) can reap it; without
// a reaper, dead sessions and their scrollback emulators accumulate for the daemon's lifetime.
onExit?: (code: number) => void
startupIngress?: PtyStartupIngressIntent
}
@@ -156,13 +138,10 @@ export class Session {
rows: size.rows,
scrollback: opts.scrollback,
wslDistro: opts.wslDistro
// No onData wiring: the daemon-side emulator must never reply to
// terminal query sequences. The renderer's xterm is the authoritative
// responder; any daemon reply races ahead via in-process parsing and
// clobbers the renderer's answer. See the comment in HeadlessEmulator.
// No onData: the daemon emulator must never reply to query sequences — the renderer's xterm is
// the authoritative responder and a daemon reply would race ahead and clobber it. See HeadlessEmulator.
})
// Why: recovery must precede listener registration; shells can emit their
// prompt synchronously as soon as onData subscribes.
// Why: seed recovery must precede listener registration; shells can emit their prompt synchronously once onData subscribes.
this._historySeeded =
opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed)
@@ -217,8 +196,7 @@ export class Session {
return false
}
this._isTerminating = true
// Why: a paused child can be blocked inside write(); resume before any
// async snapshot so it can handle termination promptly.
// Why: a paused child can be blocked inside write(); resume before any async snapshot so it handles termination promptly.
this.releaseProducerPause({ resume: true })
return true
}
@@ -232,10 +210,8 @@ export class Session {
return
}
// Why: during the post-ready flush gate window (shellState is already
// 'ready' but the queue hasn't flushed yet) we must keep queuing. Writing
// directly would let fresh input race ahead of the buffered startup
// command, changing execution order.
// Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a
// direct write would race fresh input ahead of the buffered startup command.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
this.preReadyStdinQueue.push(data)
return
@@ -252,15 +228,13 @@ export class Session {
return
}
this.emulator.resize(cols, rows)
// Why: the record stream must mirror the order operations were applied to
// the emulator, or cold-restore replay reflows at the wrong point.
// Why: the record stream must mirror the emulator's apply order, or cold-restore replay reflows at the wrong point.
this.recordPendingOutput({ kind: 'resize', cols, rows })
this.subprocess.resize(cols, rows)
}
/** Producer-side flow control: stop reading the PTY fd so the flooding
* child blocks on write (kernel backpressure). Arms the lost-resume
* failsafe; re-pausing re-arms it (main re-asserts during long floods). */
/** Producer-side flow control: stop reading the PTY fd so a flooding child blocks on write.
* Arms the lost-resume failsafe; re-pausing re-arms it. */
pauseProducer(): void {
if (this._state === 'exited' || this._disposed) {
return
@@ -302,10 +276,7 @@ export class Session {
if (!this.launchAgent) {
this.signalTerminationRoot()
} else {
// Why: agent tool children live in detached process groups a dying
// shell's SIGHUP never reaches. The bounded snapshot briefly defers the
// signal; the kill timer below starts now, so force-dispose timing is
// unaffected.
// Why: agent tool children live in detached process groups a dying shell's SIGHUP never reaches, so sweep them.
void Promise.resolve(
killWithDescendantSweep(
this.subprocess.pid,
@@ -313,8 +284,7 @@ export class Session {
this.signalTerminationRoot()
},
{
// Why: if the root exits during ps, its numeric PID can be recycled.
// Never apply that stale snapshot to a different process tree.
// Why: if the root exits during ps its PID can be recycled; never apply that stale snapshot to a different process tree.
ownsRoot: () => this.isAlive
}
)
@@ -336,15 +306,13 @@ export class Session {
try {
this.subprocess.kill()
} catch (error) {
// Why: rejected signalling is not termination. Reopen the session so a
// later graceful or destructive retry can still target the live child.
// Why: a rejected signal is not termination; reopen the session so a later retry can still target the live child.
this.resetTerminationAfterSignalFailure()
throw error
}
}
/** Starts the existing graceful-kill deadline when a coordinator owns the
* snapshot-first portion of teardown. */
/** Starts the graceful-kill deadline when a coordinator owns the snapshot-first portion of teardown. */
scheduleForceDisposeFallback(): void {
if (this.killTimer) {
return
@@ -368,8 +336,7 @@ export class Session {
this.requestForceKill()
} catch (error) {
console.warn('[Session] failed to force-kill terminating subprocess:', error)
// Why: a transient SIGKILL rejection must not consume the only
// fallback owner after graceful shutdown has already returned.
// Why: a transient SIGKILL rejection must not consume the only fallback owner after graceful shutdown returned.
if (attemptsRemaining > 1) {
this.armForceKillFallback(SESSION_FORCE_KILL_RETRY_MS, attemptsRemaining - 1)
}
@@ -388,8 +355,7 @@ export class Session {
this._isTerminating = true
this.releaseProducerPause({ resume: true })
}
// Why: destructive cleanup joins a graceful termination but escalates it
// now; waiting for the 5s timer would spend most of the physical-exit budget.
// Why: escalate a graceful termination now; waiting for the 5s timer would spend most of the physical-exit budget.
await this.requestForceKillWithRetry()
await this.waitForPhysicalExit(timeoutMs)
}
@@ -412,8 +378,7 @@ export class Session {
if (idx !== -1) {
this.attachedClients.splice(idx, 1)
}
// Why: with no attached client, nobody will ever send resumePty — a
// paused shell would sit wedged until the failsafe. Resume eagerly.
// Why: with no attached client nobody will send resumePty, so a paused shell would wedge until the failsafe; resume eagerly.
if (this.attachedClients.length === 0) {
this.releaseProducerPause({ resume: true })
}
@@ -439,10 +404,8 @@ export class Session {
return this.emulator.partialEscapeTailAnsi
}
// Why: the size the PTY actually applied (emulator dims, which Session.resize
// advances atomically with the subprocess), so the renderer can detect a
// resize that was dropped here (exited/disposed/invalid) instead of trusting
// its own last-requested size. Null on a disposed session.
// Why: returns the size the PTY actually applied (emulator dims) so the renderer can detect a
// resize dropped here (exited/disposed/invalid) instead of trusting its last-requested size.
getAppliedSize(): { cols: number; rows: number } | null {
if (this._disposed) {
return null
@@ -450,10 +413,8 @@ export class Session {
return this.emulator.getAppliedSize()
}
/** Drains the records accumulated since the last take. Runs synchronously —
* when includeSnapshot is set, the serialize happens in the same turn so no
* PTY data can land between the drain and the snapshot (which would later
* be replayed twice on cold restore). */
/** Drains records accumulated since the last take. When includeSnapshot is set it serializes in
* the same turn so no PTY data lands between drain and snapshot (which would replay twice on cold restore). */
takePendingOutput(
includeSnapshot: boolean,
opts: { teardownSnapshot?: boolean } = {}
@@ -503,21 +464,16 @@ export class Session {
this.#nudgePowerShellPromptRepaint()
}
/** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
* so PowerShell's first Enter after a clear would still repaint the prompt
* at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
* PSReadLine itself repaint at the true origin. Gated to a PowerShell
* foreground so a running command or TUI never gets a stray 0x0C, and to
* an empty prompt because PSReadLine repaints pending input at a stale
* cached row that ConPTY's fixed viewport doesn't track. */
/** Why: ConPTY's buffer clear leaves PSReadLine's cached cursor row stale, so the next prompt
* repaints below a blank gap; a form feed (Ctrl+L) forces a repaint at the true origin. Gated to a
* PowerShell foreground (else a running command/TUI gets a stray 0x0C) and an empty prompt (PSReadLine
* repaints pending input at a stale cached row ConPTY's fixed viewport doesn't track). */
#nudgePowerShellPromptRepaint(): void {
if (process.platform !== 'win32') {
return
}
// Why: before shell-ready, write() would queue the form feed behind the
// buffered startup command and deliver it at an arbitrary later moment,
// when the foreground/prompt gates below no longer hold. The nudge is
// cosmetic — skip it rather than defer it.
// Why: before shell-ready, write() would queue this form feed behind the startup command and
// fire it later when the gates below are stale; the nudge is cosmetic, so skip rather than defer.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
return
}
@@ -541,13 +497,8 @@ export class Session {
return
}
// Why: captured BEFORE the `_state = 'exited'` flip below. This check
// guards the "dispose while kill() was already in flight" case — if true,
// the child hasn't reaped yet and we need to forceKill it here (the 5s
// killTimer is also about to be cleared by #teardownSubprocess). Do NOT
// move this capture below #teardownSubprocess or the `_state = 'exited'`
// assignment — #teardownSubprocess flips `_disposed` but the invariant
// depends on the PRE-flip value of `_state`.
// Why: `wasTerminating` below must be read BEFORE the `_state = 'exited'` flip — it guards the
// "dispose while kill() in flight" case and the invariant needs the pre-flip `_state`; do NOT move it down.
this.releaseHeldShellReadyBytes()
this.startupIngress.drainAndClose()
const wasTerminating = this._isTerminating && this._state !== 'exited'
@@ -575,48 +526,31 @@ export class Session {
}
}
/** Public: fd-release-only teardown for sessions that have ALREADY exited
* (state === 'exited') but are still retained in the host's map. Callers
* MUST NOT use this on live sessions — it skips SIGKILL.
*
* Why a separate method: after handleSubprocessExit fires, proc.pid refers
* to a child that has been reaped; on POSIX that pid is eligible for reuse
* and may now belong to an unrelated process. forceKillAndDisposeSubprocess
* would send SIGKILL to that recycled pid. This method only releases the
* PTY master fd via node-pty's destroy() (which is neutralized against the
* SIGHUP-to-pid hazard by the onExit handler in pty-subprocess.ts). */
/** fd-release-only teardown for ALREADY-exited sessions still retained in the host map; skips
* SIGKILL, so callers MUST NOT use it on live sessions. Separate method because a reaped pid is
* eligible for POSIX reuse, so SIGKILL could otherwise hit an unrelated process. */
disposeSubprocess(): void {
this.#teardownSubprocess()
this._state = 'exited'
}
/** Public: orderly-shutdown path used by TerminalHost.dispose() for sessions
* that are still live. Force-kills the child (SIGKILL is not ignorable),
* then releases the PTY master fd synchronously via node-pty's destroy().
* Bypasses the 5s KILL_TIMEOUT_MS fallback so daemon shutdown reaps
* stubborn children AND frees the ptmx fd on the same tick. Does NOT fan
* out onExit to attached clients — renderer reconnects cold after daemon
* exit. Callers MUST check isAlive first; see disposeSubprocess() for the
* already-exited case. */
/** Orderly-shutdown path (TerminalHost.dispose()) for live sessions: force-kills the child, then
* synchronously frees the ptmx fd, bypassing the 5s KILL_TIMEOUT_MS fallback. Does NOT fan out
* onExit (renderer reconnects cold after daemon exit). Callers MUST check isAlive first. */
async forceKillAndDisposeSubprocess(): Promise<void> {
// Why: daemon exit cannot neutralize the native handle until a bounded
// retry is accepted and onExit proves the child was physically reaped.
// Why: daemon exit can't neutralize the native handle until a bounded retry lands and onExit proves the child was reaped.
await this.forceKillAndWaitForExit()
this.dispose()
}
/** Private: shared teardown helper called by dispose() and
* forceKillAndDisposeSubprocess(). Flips `_disposed`, clears pending timers,
* and forwards to subprocess.dispose() exactly once. Does NOT set `_state` —
* the caller owns the state transition AFTER capturing any invariants that
* depend on the pre-flip value (see the wasTerminating capture in dispose). */
/** Shared teardown for dispose()/forceKillAndDisposeSubprocess(). Does NOT set `_state` — the
* caller owns that after capturing pre-flip invariants (see the wasTerminating capture in dispose). */
#teardownSubprocess(): void {
if (this._disposed) {
return
}
this._disposed = true
// Why: never leave a paused fd behind on any teardown path — the handle's
// own dead-guard makes this a no-op when the child is already reaped.
// Why: never leave a paused fd behind on teardown; the handle's dead-guard makes this a no-op once the child is reaped.
this.releaseProducerPause({ resume: true })
if (this.killTimer) {
clearTimeout(this.killTimer)
@@ -640,8 +574,7 @@ export class Session {
try {
this.subprocess.dispose()
} catch (err) {
// Why: dispose() is documented never to throw, but if it does we must not
// prevent callers from completing their own cleanup (fanout, map removal).
// Why: dispose() should never throw, but if it does, callers must still complete their own cleanup (fanout, map removal).
console.warn('[Session] subprocess.dispose() threw:', err)
}
}
@@ -657,9 +590,7 @@ export class Session {
this.pendingOutputOverflowed = true
return
}
// Why: TUIs emit thousands of tiny chunks between checkpoint ticks;
// coalescing adjacent output keeps the take RPC and log frames compact.
// The 64KB segment cap bounds per-chunk string-append cost.
// Why: coalesce the thousands of tiny TUI chunks per tick to keep take RPC/log frames compact; 64KB cap bounds append cost.
const last = this.pendingOutputRecords.at(-1)
if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) {
last.data += record.data
@@ -690,11 +621,8 @@ export class Session {
private emitSubprocessOutput(emission: PtyIngressEmission): void {
const { data } = emission
const rawLength = emission.rawEndSeq - emission.rawStartSeq
// Why: daemon stream thinning can omit bytes before main sees them. The
// absolute count lets an authoritative snapshot cover those gaps while
// renderer reconciliation deduplicates any queued post-snapshot tail.
// Why: absolute raw count (daemon stream thinning can drop bytes) lets a snapshot cover the gaps while the renderer dedups the tail.
this.outputSequence += rawLength
// Feed data to headless emulator for state tracking
if (data.length > 0) {
this.emulator.write(data)
this.recordPendingOutput({ kind: 'output', data })
@@ -721,8 +649,7 @@ export class Session {
this._exitCode = code
this._state = 'exited'
this._isTerminating = false
// Why resume:false — the child is reaped, so there is nothing to unblock;
// only the failsafe timer must not outlive the session.
// Why resume:false — the child is reaped (nothing to unblock); only the failsafe timer must not outlive the session.
this.releaseProducerPause({ resume: false })
if (this.killTimer) {
@@ -735,20 +662,15 @@ export class Session {
}
this.postReadyFlushGate.clear()
// Why: release the ptmx fd on the natural-exit path. Without this, the
// node-pty wrapper's _socket stays alive until GC and the master fd leaks
// (see docs/fix-pty-fd-leak.md). Do NOT route through #teardownSubprocess:
// that helper flips `_disposed = true`, which would short-circuit the later
// Session.dispose() call from TerminalHost.reapSession (wired via onExit
// below) — skipping attachedClients/emulator/postReadyFlushGate cleanup.
// Why: release the ptmx fd here or node-pty's _socket leaks the master fd until GC (docs/fix-pty-fd-leak.md).
// Not via #teardownSubprocess: it flips `_disposed`, short-circuiting the later Session.dispose() reaper.
this.disposeSubprocessHandle()
for (const client of this.attachedClients) {
client.onExit(code)
}
// Why: hand off to the owner's reaper so the emulator is disposed and the
// session dropped from the host map; otherwise dead sessions accumulate.
// Why: hand off to the owner's reaper (disposes emulator, drops session from host map); else dead sessions accumulate.
this.onSessionExit?.(code)
}
@@ -758,10 +680,7 @@ export class Session {
}
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
this.shellReadyScanState = null
// Why: daemon scanning now runs before emulator/client fan-out so marker
// bytes can be stripped. If readiness never completes, preserve the
// previous behavior by releasing any held prefix before timeout or exit
// state changes discard it.
// Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it.
this.startupIngress.accept(heldBytes)
return heldBytes
}
@@ -839,8 +758,7 @@ export class Session {
}
private waitForPhysicalExit(timeoutMs: number): Promise<void> {
// Why: timed-out destructive retries must detach from an unkillable child;
// otherwise every retry stays retained until the process eventually exits.
// Why: timed-out destructive retries must detach from an unkillable child, else each retry stays retained until it exits.
return this.physicalExit.waitForExit(
timeoutMs,
() => new Error(`Timed out waiting for PTY process exit: ${this.sessionId}`)
+22 -68
View File
@@ -19,18 +19,14 @@ const itWithZsh = hasZsh ? it : it.skip
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
// Why: the shell-ready marker is emitted from zle-line-init, which only fires
// on a real TTY — spawn through node-pty instead of spawnSync.
// Why: the shell-ready marker fires from zle-line-init only on a real TTY, so spawn through node-pty not spawnSync.
async function runInteractiveZshLogin(args: {
tempHome: string
wrapperZdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/* on CI runners, whose insecure (group-
// writable) fpath dirs make the global compinit block on an interactive
// "insecure directories" [y/n] prompt before zle-line-init ever fires. The
// marker contract lives entirely in our ZDOTDIR files, which still load.
// Why: -o noglobalrcs skips /etc/zsh/*, whose insecure fpath dirs make compinit block on a [y/n] prompt before the marker fires.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], {
name: 'xterm-256color',
cols: 80,
@@ -64,16 +60,13 @@ async function runInteractiveZshLogin(args: {
return output
}
// Why: exercise an arbitrary interactive zsh rc (its own ZDOTDIR, no wrapper)
// so a test can source the marker block directly — e.g. twice, to check the
// registration is idempotent and keeps chaining the user's prior widget.
// Why: exercise an arbitrary interactive zsh rc (own ZDOTDIR, no wrapper) so a test can source the marker block directly.
async function runInteractiveZshRc(args: {
zdotdir: string
isDone: (output: string) => boolean
}): Promise<string> {
const pty = await import('node-pty')
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit
// can't block on an insecure-directory [y/n] prompt before our marker fires.
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit can't block on an insecure-directory [y/n] prompt.
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], {
name: 'xterm-256color',
cols: 80,
@@ -217,10 +210,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
// Why: guards against the zsh recursion loop that happens when the daemon
// was forked from a shell which was itself an Orca PTY. Such a shell has
// ZDOTDIR=<some>/shell-ready/zsh; propagating that unchanged would make
// the wrapper `source "$ORCA_ORIG_ZDOTDIR/.zshenv"` source itself.
// Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (recursion loop).
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
@@ -334,19 +324,13 @@ describePosix('daemon shell-ready launch config', () => {
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
// Why: add-zle-hook-widget aborts its hook chain when an earlier hook
// exits non-zero, so the marker must not be registered through it.
// Why: add-zle-hook-widget aborts its chain when an earlier hook exits non-zero, so don't register the marker through it.
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
// Why: re-source guard — skip re-capturing when we are already the bound
// widget so the prior widget chain survives a second source.
// Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source.
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
})
// Why: regression guard — oh-my-zsh vi-mode installs a raw zle-line-init
// that returns non-zero when VI_MODE_SET_CURSOR is unset. Registering the
// marker via add-zle-hook-widget let that failing widget abort the hook
// chain, so the marker never fired and every queued startup command sat on
// the daemon's pre-ready timeout (a 15s "bare shell" before the agent).
// Why: oh-my-zsh vi-mode's zle-line-init returns non-zero; add-zle-hook-widget then aborts the chain and the marker never fires.
itWithZsh(
'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)',
async () => {
@@ -401,8 +385,7 @@ describePosix('daemon shell-ready launch config', () => {
isDone: (current) =>
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
})
// Why: the marker widget chains to the previously installed widget, so
// an azhw dispatcher registered by user config must keep dispatching.
// Why: the marker widget chains to the prior widget, so a user-registered azhw dispatcher must keep dispatching.
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
expect(output).toContain(userHookOutput)
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
@@ -415,10 +398,7 @@ describePosix('daemon shell-ready launch config', () => {
15_000
)
// Why: the marker block is normally sourced once per shell, but a re-source
// (nested Orca, manual re-source) must stay idempotent — it must keep
// chaining the user's original zle-line-init instead of clobbering the
// captured function to empty and silently dropping it on later prompts.
// Why: a re-source (nested Orca, manual) must stay idempotent — keep chaining the user's original zle-line-init.
itWithZsh(
'keeps chaining the prior zle-line-init widget when the marker block is sourced twice',
async () => {
@@ -448,8 +428,7 @@ describePosix('daemon shell-ready launch config', () => {
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
output.indexOf(userHookOutput)
)
// Why: idempotent — the marker must fire exactly once per prompt, not
// duplicated by the second registration.
// Why: idempotent — the marker must fire exactly once per prompt, not duplicated by the second registration.
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
} finally {
rmSync(zdotdir, { recursive: true, force: true })
@@ -498,9 +477,7 @@ describePosix('daemon shell-ready launch config', () => {
expect(bashRc).toContain(ompWrapperLine)
})
// Why: regression guard for issue #2422. The daemon-side bash wrapper must
// emit OSC 133 C/D so SSH/remote bash sessions also clear stale 'working'
// agent rows when the foreground command exits.
// Why: regression guard for issue #2422 bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
@@ -512,14 +489,11 @@ describePosix('daemon shell-ready launch config', () => {
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
expect(bashRc).toContain('printf "\\033]133;C\\007"')
// precmd is prepended (captures $? first) and the epilogue is appended last,
// so a framework that must be last in PROMPT_COMMAND stays between them.
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
expect(bashRc).toContain(
'PROMPT_COMMAND="__orca_osc133_precmd${PROMPT_COMMAND:+;${PROMPT_COMMAND}};__orca_osc133_epilogue"'
)
// The final DEBUG arming runs after PROMPT_COMMAND setup so the rcfile's own
// commands are not mistaken for a foreground command (lastIndexOf skips the
// identical re-arm inside __orca_osc133_epilogue).
// DEBUG is armed after PROMPT_COMMAND setup so rcfile commands aren't seen as foreground; lastIndexOf skips the epilogue's re-arm.
expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
bashRc.indexOf('PROMPT_COMMAND="__orca_osc133_precmd')
)
@@ -562,9 +536,7 @@ describePosix('daemon shell-ready launch config', () => {
'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
// Minimal bash-preexec imitation (iTerm2/starship setups): re-arms its own
// DEBUG trap from PROMPT_COMMAND at the first prompt — silencing Orca's
// trap — and dispatches preexec_functions with the command as $1.
// Minimal bash-preexec imitation: re-arms its own DEBUG trap from PROMPT_COMMAND at first prompt, silencing Orca's trap.
writeFileSync(
join(userDataPath, '.bash_profile'),
[
@@ -590,13 +562,7 @@ describePosix('daemon shell-ready launch config', () => {
'dispatches a non-empty preexec_functions against the real command, not Orca hooks',
async () => {
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
// Why: Orca's epilogue captures bash-preexec's re-armed DEBUG trap and
// chains it. A real preexec callback must fire against the user's command —
// not __orca_osc133_epilogue. Mirror upstream bash-preexec faithfully: it
// enables `functrace` (so Orca's `trap -p DEBUG` capture sees its trap),
// defers that install to the first prompt via PROMPT_COMMAND, and reads the
// command from `history` (so DEBUG fires on prompt hooks never dispatch a
// phantom). The naive `$BASH_COMMAND` imitation does none of these.
// Why: the epilogue chains bash-preexec's re-armed DEBUG trap, so a real preexec callback must fire against the user's command.
writeFileSync(
join(userDataPath, '.bash_profile'),
[
@@ -648,9 +614,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
// Why: users who run a custom zsh dotfiles directory legitimately set
// ZDOTDIR before launching Orca. We only want to reject the self-loop
// case — any real user ZDOTDIR must round-trip so their configs load.
// Why: only the wrapper self-loop should be rejected; a real user ZDOTDIR must round-trip so their configs load.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/.config/zsh'
try {
@@ -668,9 +632,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('rejects inherited ZDOTDIR ending in /shell-ready/zsh even with a trailing slash', async () => {
// Why: `endsWith('/shell-ready/zsh')` without normalization is bypassed by
// a trailing slash, which some shell startup scripts add. Pinning this case
// guards against a regression that would reintroduce the recursion loop.
// Why: a trailing slash bypasses `endsWith('/shell-ready/zsh')`, reintroducing the recursion loop if unguarded.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh/'
@@ -694,10 +656,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => {
// Why: a bare `/` (or `////`) normalizes to empty and is never a user's
// real zsh config root; sourcing `/.zshenv` would silently no-op. Falling
// back to HOME matches what the wrapper already assumes when ZDOTDIR is
// unset.
// Why: a bare `/` normalizes to empty (never a real config root), so fall back to HOME as when ZDOTDIR is unset.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/'
@@ -721,9 +680,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => {
// Why: the guard must match the suffix, not a substring — a user directory
// like `/Users/alice/shell-ready/zsh-custom` should round-trip unchanged.
// Pinning this case prevents an over-eager `includes` swap in the future.
// Why: guard must match suffix not substring — `/Users/alice/shell-ready/zsh-custom` must round-trip unchanged.
const previousZdotdir = process.env.ZDOTDIR
process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom'
try {
@@ -740,9 +697,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
// Why: PR #1737 sourced .zshenv inside a wrapper function, which broke
// common patterns like "typeset -U path". The fix must keep .zshenv at
// zsh top level while still capturing the ZDOTDIR it resolved.
// Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level.
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
@@ -760,8 +715,7 @@ describePosix('daemon shell-ready launch config', () => {
})
it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => {
// Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper
// should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME.
// Why: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME.
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
+15 -50
View File
@@ -49,9 +49,7 @@ export class TerminalHost {
}
const existing = this.sessions.get(opts.sessionId)
// Why: async descendant capture must finish before anyone can attach or
// dispose/recreate this id. Disposing here would kill the root before the
// snapshot and reattaching would hand out a doomed session.
// Why: async descendant capture must finish before attach/recreate, or we hand out a doomed session.
if (this.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
}
@@ -71,12 +69,10 @@ export class TerminalHost {
}
if (existing?.isAlive && existing.isTerminating) {
// Why: replacing a SIGKILLed-but-unreaped child would lose ownership of
// its native handles and let the same session id hide two generations.
// Why: replacing a SIGKILLed-but-unreaped child would leak its native handles and hide two generations under one id.
throw new Error(`Session "${opts.sessionId}" is terminating`)
}
// Clean up dead session if present
if (existing) {
existing.dispose()
this.sessions.delete(opts.sessionId)
@@ -102,11 +98,7 @@ export class TerminalHost {
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
})
// Why: the caller computed shellReadySupported from the preferred shell,
// before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready
// marker, so keeping the stale flag would queue startup commands until the
// shell-ready timeout and bracketed-paste-wrap them for a line editor
// without paste mode.
// Why: the pre-spawn flag goes stale if spawn fell back to a shell (e.g. /bin/sh) that never emits the ready marker.
const shellReadySupported =
(opts.shellReadySupported ?? false) &&
(subprocess.shellPath === undefined ||
@@ -123,10 +115,7 @@ export class TerminalHost {
historySeed: opts.historySeed,
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
wslDistro,
// Why: reap the dead session (dispose emulator + drop from the map) the
// moment its subprocess exits, instead of retaining it for the daemon's
// lifetime. Nothing reads a dead session's emulator (getSnapshot/
// takePendingOutput/listSessions all skip !isAlive sessions).
// Why: reap the dead session (dispose emulator + drop from map) on subprocess exit, not at daemon shutdown.
onExit: () => this.reapSession(opts.sessionId),
...(opts.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
@@ -138,18 +127,10 @@ export class TerminalHost {
const token = session.attachClient(opts.streamClient)
if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) {
// Why: startup commands must run inside the long-lived interactive shell
// the daemon keeps for the pane. Session.write() handles the shell-ready
// barrier for supported shells and falls back to an immediate write for
// unsupported ones.
// Why CR on Windows: PowerShell's PSReadLine and cmd.exe submit the line
// on CR (`\r`); a bare LF leaves the command typed but unsubmitted, so
// the user would need to press Enter after Orca launches the agent or
// setup script. POSIX shells accept CR as Enter under ICRNL.
// Why: startup commands must run inside the long-lived interactive shell the daemon keeps for the pane.
// Why CR on Windows: PSReadLine/cmd.exe submit on CR; a bare LF leaves it unsubmitted (POSIX accepts CR via ICRNL).
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: multiline startup prompts are pasted literally via bracketed paste
// only for Orca-wrapped bash/zsh, which is exactly when the shell-ready
// barrier is supported; other shells keep the raw submit path.
// Why: bracketed-paste only for Orca-wrapped bash/zsh (== shell-ready supported); other shells use the raw submit path.
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
@@ -180,9 +161,7 @@ export class TerminalHost {
this.getAliveSession(sessionId).resize(cols, rows)
}
// Why null-not-throw (unlike write/resize): pause/resume are best-effort
// flow-control hints; a session that exited while the notify was in flight
// must not surface an error or a synthetic exit.
// Why null-not-throw (unlike write/resize): pause/resume are best-effort hints against a session that may have exited.
pauseProducer(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
@@ -208,10 +187,7 @@ export class TerminalHost {
return Promise.resolve(killed)
}
// Why: dispose a dead session's headless emulator and drop it from the map so
// exited terminals don't pin ~5000 rows of scrollback for the daemon's life.
// No-ops on live sessions (a live session must never be disposed here) and on
// already-reaped/unknown ids. Wired as the Session onExit hook.
// Why: dispose a dead session's emulator so exited terminals don't pin ~5000 rows of scrollback for the daemon's life.
private reapSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session || session.isAlive) {
@@ -236,17 +212,12 @@ export class TerminalHost {
if (tracked) {
return tracked
}
// Why: the emulator's cwd is null until the shell emits OSC 7. Orca's
// bash/zsh rcfiles ship with OSC 133 markers but not OSC 7, so the
// tracked value stays null through the entire session for most users.
// Fall back to the live process cwd via /proc/<pid>/cwd (Linux) or
// lsof (macOS). Matches the LocalPtyProvider.getCwd fallback.
// Why: emulator cwd stays null (Orca rcfiles emit OSC 133 not OSC 7), so fall back to the live process cwd.
const resolved = await resolveProcessCwd(session.pid)
return resolved || null
}
// Why: returns null (not throws) for a dead/missing session — this is fetched
// for the tab-bar icon, so a vanished pane should quietly yield "no agent".
// Why: null-not-throw — fetched for the tab-bar icon, so a vanished pane should quietly yield "no agent".
getForegroundProcess(sessionId: string): string | null {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
@@ -267,9 +238,7 @@ export class TerminalHost {
this.getAliveSession(sessionId).clearScrollback()
}
// Why: unlike getAliveSession (which throws), this returns null for dead/missing
// sessions. Checkpoint is best-effort — a session that exited between the timer
// firing and the RPC arriving should not throw.
// Why: null-not-throw (unlike getAliveSession) — checkpoint is best-effort against a session that may have just exited.
getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
@@ -278,8 +247,7 @@ export class TerminalHost {
return session.getSnapshot(opts)
}
// Why: scan-authority handoff seed (null-not-throw like getSnapshot) — the
// emulator's dangling incomplete escape at the current stream position.
// Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position.
getPartialEscapeTailAnsi(sessionId: string): string {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
@@ -288,9 +256,7 @@ export class TerminalHost {
return session.getPartialEscapeTailAnsi()
}
// Why: read-only readback of the size the PTY actually applied (null-not-throw
// like getSnapshot). The renderer compares this against xterm to detect a
// resize that was dropped/coerced daemon-side and re-assert it.
// Why: renderer diffs this against xterm to detect a dropped/coerced daemon-side resize; null-not-throw like getSnapshot.
getAppliedSize(sessionId: string): { cols: number; rows: number } | null {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
@@ -299,8 +265,7 @@ export class TerminalHost {
return session.getAppliedSize()
}
// Why: same null-not-throw semantics as getSnapshot — incremental
// checkpoints are best-effort against sessions that may have just exited.
// Why: null-not-throw like getSnapshot — incremental checkpoints are best-effort against a just-exited session.
takePendingOutput(
sessionId: string,
includeSnapshot: boolean,
+22 -70
View File
@@ -62,11 +62,7 @@ export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
// Why: when a newer Orca release adds a top-level key to `orca.yaml` (like
// `issueCommand` was added here), older versions that don't recognise it will
// return `null` from `parseOrcaYaml` and show a confusing "could not be parsed"
// error. Detecting well-formed but unrecognised keys lets the UI suggest an
// update instead of implying the file is broken.
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'issueCommand',
@@ -74,17 +70,12 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'environmentRecipes'
])
/**
* Return true when `orca.yaml` contains at least one top-level key that this
* version of Orca does not handle.
*/
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
for (const line of iterateLfScriptLines(content)) {
// Why: bare `key:` at end-of-line (no trailing space) is valid YAML for
// a mapping with a block value on the next line. Match both forms so
// newer keys like `futureFeature:\n nested` are still detected.
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
return true
@@ -97,9 +88,7 @@ export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
}
// ─── Issue command files ────────────────────────────────────────────────
// Why: `orca.yaml` is the tracked, project-wide defaults surface, while
// `.orca/issue-command` remains the per-user override. Keeping the local file in
// `.orca/` lets users customize agent automation without editing committed config.
// Why: `.orca/issue-command` is the per-user override; `orca.yaml` is the tracked project default.
const ORCA_DIR = '.orca'
const ISSUE_COMMAND_FILENAME = 'issue-command'
@@ -150,9 +139,7 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
/**
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
* Creates `.orca/` and ensures it is in `.gitignore` on first write.
* If content is empty, deletes only the override so the shared `orca.yaml`
* command becomes effective again.
* Empty content deletes the override so the shared `orca.yaml` command applies again.
*/
export function writeIssueCommand(repoPath: string, content: string): void {
const filePath = getIssueCommandFilePath(repoPath)
@@ -172,16 +159,12 @@ export function writeIssueCommand(repoPath: string, content: string): void {
writeFileSync(filePath, `${trimmed}\n`, 'utf-8')
} catch (err) {
console.error('[hooks] Failed to write issue command:', err)
// Why: re-throw so the error propagates through the IPC handler to the
// renderer, which already has .catch() ready to surface write failures.
// Why: re-throw so the IPC handler surfaces the write failure to the renderer's .catch().
throw err
}
}
/**
* Ensure `.orca` is listed in the repo's `.gitignore` so the per-user
* directory is never accidentally committed.
*/
/** Ensure `.orca` is in `.gitignore` so the per-user directory is never committed. */
function ensureOrcaDirIgnored(repoPath: string): void {
const gitignorePath = join(repoPath, '.gitignore')
try {
@@ -239,9 +222,7 @@ export function getEffectiveHooksFromConfig(
return null
}
// Why: committed `orca.yaml` and local Settings commands can intentionally
// coexist, but the source policy defines whether the committed file is an
// authoritative boundary, local settings are authoritative, or both run.
// Why: committed `orca.yaml` and local Settings can coexist; the source policy decides which is authoritative.
return {
scripts: {
...(setup ? { setup } : {}),
@@ -305,8 +286,7 @@ export function getDefaultTabsLaunch(
hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim())
}
)
// Why: default tab commands come from committed `orca.yaml`; a repo set to
// local-only may still use shared titles/colors, but must not execute them.
// Why: local-only repos may use shared tab titles/colors but must not run the committed orca.yaml commands.
const canRunSharedCommands = sharedCommandPolicy !== 'local-only'
const runCommands =
hasCommands && canRunSharedCommands ? shouldRunSetupForCreate(repo, decision) : false
@@ -394,8 +374,7 @@ function getHookWslContext(
return null
}
// Why: project runtime can route a normal Windows checkout through WSL; hooks
// must cd to the Linux view of that path rather than running in cmd.exe.
// Why: project runtime can route a Windows checkout through WSL, so hooks need the Linux view of the path.
return {
distro: wslDistro,
linuxPath: toLinuxPath(cwd)
@@ -412,11 +391,7 @@ export function buildWindowsRunnerScript(script: string): string {
continue
}
// Why: setup commands often invoke `npm`/`pnpm`, which are batch files on
// Windows. Calling one batch file from another without `call` never returns
// to later lines, and plain newline-separated commands also keep running
// after failures. Wrap each line in `call` and bail on non-zero exit codes
// so the generated runner matches the fail-fast behavior of `set -e`.
// Why: npm/pnpm are Windows batch files; `call` each line and bail on errorlevel for set -e fail-fast behavior.
runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n`
}
@@ -459,8 +434,7 @@ export function createSetupRunnerScript(
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
...getSetupEnvVars(repo, worktreePath),
// Why: the visible Setup terminal is still unattended automation; user
// terminal opt-out must not let its git commands open credential UI.
// Why: the Setup terminal is unattended automation, so force the credential guard regardless of user opt-out.
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
}
@@ -496,11 +470,7 @@ export function createIssueCommandRunnerScript(
command: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): WorktreeSetupLaunch {
// Why: long issue-automation commands are user-visible shell input when
// written directly to the PTY, so terminal line editors can wrap or truncate
// them before execution. Writing the real command into a runner script keeps
// the shell startup path short and mirrors the already-stable setup runner
// flow instead of inventing a second launch mechanism.
// Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them.
return createWorktreeRunnerScript(
repo,
worktreePath,
@@ -519,19 +489,14 @@ function createWorktreeRunnerScript(
waitForAgentStartup?: boolean
): WorktreeSetupLaunch {
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
// Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
// Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path.
const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh`
let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget)
// Why: for WSL worktrees, getGitPath returns a Linux path (e.g. /home/user/...)
// because git runs inside WSL. Convert it to a Windows UNC path so mkdirSync
// and writeFileSync (which run on Windows) can access it.
// Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it.
if (wslWorktree) {
const wslInfo = getHookWslContext(worktreePath, runtimeTarget)
if (wslInfo?.distro) {
@@ -545,14 +510,11 @@ function createWorktreeRunnerScript(
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
} else {
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
// Why: chmod via UNC paths to WSL filesystem is supported by Windows and
// sets the execute bit correctly inside WSL.
// Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
}
// Why: when the worktree is on WSL, env vars like ORCA_ROOT_PATH and
// ORCA_WORKTREE_PATH contain Windows UNC paths. The setup script runs
// inside WSL bash, so translate them to Linux paths.
// Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths.
if (wslWorktree) {
for (const key of Object.keys(envVars)) {
envVars[key] = toLinuxPath(envVars[key])
@@ -587,16 +549,11 @@ export function runHook(
const wslInfo = getHookWslContext(cwd, runtimeTarget)
if (wslInfo) {
// Why: use execFile('wsl.exe', [...]) instead of exec() to bypass the
// Windows shell (cmd.exe). exec() always routes through a shell, and
// cmd.exe doesn't understand single-quote escaping — it would mangle
// paths/scripts containing %, ^, &, |, etc.
// Why: use execFile to bypass cmd.exe, which mangles single-quote escaping of %, ^, &, |, etc.
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
const escapedScript = script.replace(/'/g, "'\\''")
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
// Why: translate ORCA_ROOT_PATH / ORCA_WORKTREE_PATH to Linux paths so
// hook scripts that reference $ORCA_WORKTREE_PATH get usable paths
// inside WSL, not Windows UNC paths.
// Why: hook scripts run inside WSL, so translate the ORCA_* Windows UNC paths to Linux paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
@@ -632,8 +589,7 @@ export function runHook(
}
}
// Why: Node's execFile timeout only signals wsl.exe; if no callback
// arrives, hook setup/archive must still unblock after HOOK_TIMEOUT.
// Why: execFile's timeout only signals wsl.exe; force-unblock after HOOK_TIMEOUT if no callback arrives.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`))
@@ -672,11 +628,7 @@ export function runHook(
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
// Why: setup/archive hooks run unattended, so a `git fetch`/`submodule
// update` inside one must never make Git Credential Manager pop its
// "Connect to GitHub" OAuth window on Windows and loop when the network
// can't complete it (issue #7652). The guard keeps the credential
// helper, so cached auth still works; only the interactive prompt dies.
// Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652).
env: promptGuardShellEnv({
...process.env,
...getSetupEnvVars(repo, cwd)
+132 -423
View File
File diff suppressed because it is too large Load Diff
+15 -51
View File
@@ -25,10 +25,7 @@ import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-c
const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500
const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox'
// Why: macOS 15's `plutil -extract <key> json` aborts on the (pure-string)
// input-source array and the on-disk plist lags cfprefsd; read live prefs via
// `defaults export`, extract as xml1 (dodges the json bug), then convert to JSON.
// Absolute paths so a GUI-launched app's minimal PATH can't shadow the tools.
// Why: defaults export reads live prefs (on-disk plist lags cfprefsd); xml1 dodges plutil's json abort on macOS 15 input-source arrays; absolute paths so a minimal PATH can't shadow the tools.
const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [
`/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`,
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -',
@@ -69,8 +66,7 @@ async function pickFloatingWorkspaceDirectory(
): Promise<string | null> {
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const options = {
// Why: this picker grants access to an existing workspace directory.
// Creation belongs to explicit file/write actions, not typeahead input.
// Why: this picker only grants access to an existing directory; creation belongs to explicit file actions.
properties: ['openDirectory']
} satisfies Electron.OpenDialogOptions
const result = parentWindow
@@ -80,8 +76,7 @@ async function pickFloatingWorkspaceDirectory(
return null
}
const selectedDir = result.filePaths[0]
// Why: a user-approved picker selection is a trust grant for later Floating
// Workspace markdown creation, unlike arbitrary typed settings text.
// Why: a user-approved picker selection is a trust grant for later markdown creation, unlike typed settings text.
await grantFloatingWorkspaceDirectory(store, selectedDir)
return selectedDir
}
@@ -94,8 +89,7 @@ function getFeatureWallAssetBaseUrl(): string {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
const vitePath = assetDir.split(path.sep).join('/')
const absoluteVitePath = vitePath.startsWith('/') ? vitePath : `/${vitePath}`
// Why: the dev renderer is served from http://localhost, where Chromium
// blocks file:// image loads. Vite's /@fs route serves the same local media.
// Why: Chromium blocks file:// image loads from the http dev origin; Vite's /@fs route serves the same local media.
return new URL(`/@fs${absoluteVitePath}/`, process.env.ELECTRON_RENDERER_URL).toString()
}
@@ -110,8 +104,7 @@ function resolveDevFeatureWallAssetDir(): string {
path.join(process.cwd(), relativeDir)
]
// Why: E2E launches out/main/index.js, so app.getAppPath() can point at
// out/main even though development resources still live at the repo root.
// Why: E2E launches out/main, so app.getAppPath() can point there while dev resources live at the repo root.
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]
}
@@ -124,10 +117,7 @@ function readCommandStdout(
let settled = false
let child: ReturnType<typeof spawn> | undefined
// Why: the probe runs a `/bin/sh -c` pipeline, so signaling only the shell
// orphans wedged `defaults`/`plutil` stages on a stuck cfprefsd. Spawning
// detached makes the child a process-group leader, so one negative-pid
// SIGKILL reaps the shell and every stage; child.kill() covers the fallback.
// Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group.
const killTree = (): void => {
if (!child?.pid) {
return
@@ -139,9 +129,7 @@ function readCommandStdout(
}
}
// Why: short timeout so a wedged macOS preference probe (corporate-managed
// config, sandbox policy, ...) never holds the handle indefinitely. This
// manual timer is the only timeout guard, so it owns the process-group kill.
// Why: short timeout so a wedged macOS probe never hangs; this timer owns the process-group kill.
const timer = setTimeout(() => {
if (settled) {
return
@@ -171,8 +159,7 @@ function readCommandStdout(
killTree()
settle(() => reject(error))
}
// Why: an unhandled Readable 'error' would crash the main process; treat a
// stdout read failure the same as a child spawn error.
// Why: an unhandled Readable 'error' would crash the main process; treat stdout errors like spawn errors.
child.stdout?.on('error', failWith)
child.on('error', failWith)
child.on('close', (code, signal) => {
@@ -276,47 +263,27 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable())
ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable())
// Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman
// IME all report a US-QWERTY base layer to navigator.keyboard.getLayoutMap()
// — the layout-fingerprint probe in the renderer therefore classifies
// them as 'us' and flips macOptionIsMeta=true, silently swallowing every
// Option+letter composition (#1205: Option+A → å / ą is dropped). The
// macOS-shipped `com.apple.HIToolbox` preferences name the actual input
// mode when one is selected, falling back to the keyboard layout ID
// (e.g. `com.apple.keylayout.ABC` vs `com.apple.keylayout.US`), which
// the renderer uses as an authoritative override. Non-Darwin platforms
// have no equivalent and return null so the fingerprint stays the only
// signal.
// Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it.
ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise<string | null> => {
if (process.platform !== 'darwin') {
return null
}
try {
// Why: async so the probe never blocks the main-process event loop.
// The probe re-runs on every window focus-in (see option-as-alt-probe.ts),
// and a blocking execFileSync would briefly stall unrelated IPC each
// time the user Alt-Tabbed back into the app.
// Why: async so the focus-in probe (see option-as-alt-probe.ts) never blocks the main event loop.
const stdout = await readKeyboardInputSourceId()
const trimmed = stdout?.trim() ?? ''
return trimmed.length > 0 ? trimmed : null
} catch {
// Why: macOS preference probes can fail when keys are absent (first boot
// before any input-source interaction), or when sandboxed. Treat that as
// "no signal" — the fingerprint still runs as fallback.
// Why: probe can fail (missing keys on first boot, sandbox) — treat as "no signal" and fall back to the fingerprint.
return null
}
})
ipcMain.handle('app:relaunch', async () => {
// Why: small delay lets the renderer finish painting any "Restarting…"
// UI state before the window tears down. `app.relaunch()` schedules a
// spawn; `app.exit(0)` triggers the actual quit without invoking
// before-quit handlers that could block on confirmation dialogs.
// Mark shutdown first because app.exit() can bypass the usual quit latch.
// Why: brief delay lets the renderer paint "Restarting…" before the window tears down.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
// Why: app.exit(0) skips before-quit/will-quit, so clean the Windows tray
// explicitly before relaunching to avoid a stale notification-area icon.
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()
relaunchApp('renderer-request')
app.exit(0)
@@ -324,9 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
})
ipcMain.handle('app:restart', async () => {
// Why: the hidden admin restart should mirror the update relaunch path:
// schedule a new Orca process, then use the normal quit pipeline so daemon
// checkpoints, runtime metadata, and telemetry flush before exit.
// Why: use the normal quit pipeline so daemon checkpoints and telemetry flush before exit.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
relaunchApp('admin-restart')
@@ -357,8 +322,7 @@ async function runBeforeRelaunchCleanup(
try {
await onBeforeRelaunch?.()
} catch (error) {
// Why: restart/relaunch must not get trapped if best-effort shutdown
// cleanup fails; the cleanup path logs without exposing secret contents.
// Why: best-effort cleanup must never block relaunch; log only error.name to avoid leaking secrets.
console.warn(
'[app] Pre-relaunch cleanup failed; continuing relaunch:',
error instanceof Error ? error.name : typeof error
+20 -63
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: filesystem authorization keeps root
discovery, canonicalization, and registered-worktree cache checks together so
the security boundary is auditable end to end. */
/* eslint-disable max-lines -- Why: keeps the filesystem-auth security boundary auditable end to end. */
import { resolve, relative, dirname, basename, isAbsolute, sep } from 'node:path'
import { realpathSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
@@ -13,13 +11,7 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate for the whole session (file drops,
// terminal link opens, editor/composer/notebook opens). Bound the set with LRU
// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long
// session touching many distinct external files cannot grow it (or the O(n) auth
// scan in isPathAllowed) without limit. Every caller re-authorizes a path right
// before operating on it, so evicting a stale entry is self-healing and does not
// weaken the security boundary.
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set<string>()
const registeredWorktreeRoots = new Set<string>()
@@ -32,8 +24,7 @@ type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
function rememberAuthorizedExternalPath(path: string): void {
// Delete-then-add keeps re-authorized (actively used) paths most-recent so
// eviction only sheds the oldest never-re-touched entries.
// Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries.
authorizedExternalPaths.delete(path)
authorizedExternalPaths.add(path)
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
@@ -56,17 +47,14 @@ export function authorizeExternalPath(targetPath: string): void {
export function invalidateAuthorizedRootsCache(): void {
registeredWorktreeRootsDirty = true
// Why: dirty roots cannot be trusted for auth short-circuits. Fresh
// worktrees:list results will seed safe per-repo roots before a full rebuild.
// Why: dirty roots can't be trusted for auth short-circuits; fresh worktrees:list seeds safe per-repo roots before a full rebuild.
registeredWorktreeRoots.clear()
registeredWorktreeRootsByRepo.clear()
registeredWorktreeRootRepoIds.clear()
}
function getLocalRepos(store: Store) {
// Why: SSH repo paths are meaningful on the remote host. Treating them as
// local roots can both authorize unrelated local folders and probe paths
// that Orca should only touch through the SSH provider.
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
return store.getRepos().filter((repo) => !repo.connectionId)
}
@@ -148,11 +136,8 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string
return true
}
const rel = relative(resolvedBase, resolvedTarget)
// rel must not be ".."/"../..." or an absolute path (e.g. different drive on Windows)
// [Security Fix]: Added !isAbsolute(rel) to prevent drive traversal bypasses on Windows
// where relative('D:\\repo', 'C:\\etc\\passwd') returns absolute path 'C:\\etc\\passwd'
// Why: Windows path.relative() already treats drive/root casing as equivalent;
// rejoining and comparing strings would deny valid `c:\repo` descendants of `C:\Repo`.
// Security: reject "..", "../" or an absolute rel — on Windows relative() returns absolute across drives, which would bypass drive-traversal checks.
// Use isAbsolute, not rejoin+compare: Windows path.relative() ignores drive/root casing, so rejoining would deny valid c:\repo under C:\Repo.
return rel !== '' && !(rel === '..' || rel.startsWith(`..${sep}`)) && !isAbsolute(rel)
}
@@ -191,15 +176,8 @@ export function isPathAllowed(targetPath: string, store: Store): boolean {
}
export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
// Why: repos are processed with bounded parallelism so the cache rebuild
// keeps the Windows speedup without spawning one git process per repo.
//
// Why no realpath() here: this rebuild runs on repo/worktree invalidation,
// so canonicalizing every repo root would repeatedly touch TCC-protected
// folders on macOS even when the user is idle. The actual
// file handlers still canonicalize the specific target path before any
// destructive or read/write operation, so the security boundary remains
// enforced where it matters.
// Why: bounded parallelism keeps the Windows speedup without one git process per repo.
// Why no realpath here: canonicalizing every root on invalidation would trigger macOS TCC prompts; handlers still canonicalize the target before any operation.
const repos = getLocalRepos(store)
const perProjectResults = await mapWithConcurrency(
repos,
@@ -213,10 +191,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
roots.push(resolve(worktree.path))
}
} catch (error) {
// Why: a single inaccessible repo (EACCES, EIO, etc.) must not break
// the entire cache rebuild — that would disable File Explorer and
// Quick Open for all other repos. We skip the failing repo and let
// the rest proceed.
// Why: one inaccessible repo (EACCES/EIO) must not break the whole rebuild and disable File Explorer/Quick Open for the rest; skip it.
console.warn(`[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, error)
}
return { repoId: repo.id, roots }
@@ -306,11 +281,7 @@ export function isENOENT(error: unknown): boolean {
export type ResolveAuthorizedPathOptions = {
/**
* When true, canonicalize the parent directory but preserve the leaf so
* operations target the symlink itself rather than its destination. Required
* for delete and rename following the symlink would trash or rename the
* target file (which can live outside allowed roots, or be another tracked
* file a symlink inside the worktree happens to point at).
* Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots).
*/
preserveSymlink?: boolean
}
@@ -326,9 +297,7 @@ export async function resolveAuthorizedPath(
}
if (options.preserveSymlink) {
// Canonicalize the parent so symlinks in ancestors cannot redirect us
// outside allowed roots, but keep the final segment untouched so callers
// (delete/rename) act on the link itself.
// Canonicalize the parent so ancestor symlinks can't redirect outside allowed roots, but keep the leaf so delete/rename act on the link itself.
let realParent: string
try {
realParent = await realpath(dirname(resolvedTarget))
@@ -350,8 +319,7 @@ export async function resolveAuthorizedPath(
}
try {
// Why: Windows/WSL realpath can return UNC-shaped paths that still need to
// compare against the resolved allow-list roots used by this module.
// Why: Windows/WSL realpath can return UNC-shaped paths; re-resolve to compare against this module's allow-list roots.
const realTarget = resolve(await realpath(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
@@ -393,9 +361,7 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
if (parent === existingAncestor) {
throw error
}
// Why: create/copy callers intentionally create missing parents after
// auth. Canonicalize the nearest existing ancestor so symlink escapes are
// still caught without rejecting legitimate nested paths.
// Why: create/copy make missing parents after auth; canonicalize nearest existing ancestor to catch symlink escapes without rejecting nested paths.
missingSegments.unshift(basename(existingAncestor))
existingAncestor = parent
}
@@ -425,9 +391,7 @@ async function isPathAllowedIncludingRegisteredWorktrees(
await ensureAuthorizedRootsCache(store)
// Why: external linked worktrees are already trusted for git operations.
// Cache their normalized roots once and reuse that index so quick-open and
// file explorer do not spawn `git worktree list` on every filesystem read.
// Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time.
return (
isRegisteredWorktreePath(targetPath) ||
(await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath))
@@ -437,16 +401,13 @@ async function isPathAllowedIncludingRegisteredWorktrees(
/**
* Resolve and verify that a worktree path belongs to a registered repo.
*
* Why this doesn't use resolveAuthorizedPath: linked worktrees can live outside
* repo/workspace roots. Git operations trust exact worktree registration from
* `git worktree list`, not directory containment.
* Why not resolveAuthorizedPath: linked worktrees can live outside repo/workspace roots; git trusts exact `git worktree list` registration, not containment.
*/
export async function resolveRegisteredWorktreePath(
worktreePath: string,
store: Store
): Promise<string> {
// Reject obviously malformed paths early — mirrors the null-byte check in
// validateGitRelativeFilePath and prevents probing via realpath.
// Reject malformed paths (null byte) early to prevent probing via realpath.
if (!worktreePath || worktreePath.includes('\0')) {
throw new Error('Access denied: invalid worktree path')
}
@@ -464,8 +425,7 @@ export async function resolveRegisteredWorktreePath(
return resolvedTarget
}
// Resolve through symlinks only after the cheap registered-root check.
// On macOS, realpath() can itself trigger TCC prompts for protected roots.
// Resolve symlinks only after the cheap registered-root check: on macOS realpath() can trigger TCC prompts.
const normalizedTarget = await normalizeExistingPath(resolvedTarget)
if (registeredWorktreeRoots.has(normalizedTarget)) {
return normalizedTarget
@@ -505,8 +465,7 @@ async function isPathAllowedByCanonicalAllowedRoot(
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
}
// Why: active file operations may resolve `/var` to `/private/var` on
// macOS. Canonicalize only the matched root instead of the whole repo set.
// Why: macOS resolves /var/private/var; canonicalize only the matched root, not the whole repo set.
const canonicalRoot = await normalizeExistingPath(resolvedRoot)
if (isDescendantOrEqual(targetPath, canonicalRoot)) {
return true
@@ -539,9 +498,7 @@ async function isPathAllowedByCanonicalRegisteredRoot(
if (!isDescendantOrEqual(targetPath, canonicalRoot)) {
return false
}
// Why: #1524 stopped realpath'ing every worktree root during background
// refreshes to avoid macOS privacy prompts. Cache only the root the user is
// actively accessing so /var→/private/var aliases work without broad probes.
// Why: #1524 stopped realpath'ing every root (macOS privacy prompts); cache only the actively-accessed root so /var→/private/var aliases resolve.
registeredWorktreeRoots.add(canonicalRoot)
return true
}
+55 -161
View File
@@ -1,8 +1,4 @@
/* eslint-disable max-lines -- Why: filesystem-watcher centralizes native
(@parcel/watcher), WSL-native snapshot, and SSH remote watcher lifecycles in
one module so subscription/cleanup invariants stay auditable from a single
file. Splitting by transport would scatter the shared debounce/coalesce
helpers and the common batch-flush path across three files. */
/* eslint-disable max-lines -- Why: one module keeps native (@parcel/watcher), WSL-snapshot, and SSH watcher lifecycle invariants and shared debounce/coalesce helpers auditable in one file. */
import { ipcMain, type WebContents } from 'electron'
import * as path from 'node:path'
import { stat } from 'node:fs/promises'
@@ -24,10 +20,7 @@ import {
WatcherChildCapacityError
} from './parcel-watcher-child-registry'
import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate'
// Why: high-churn directories are suppressed at the native watcher level so
// events never leave the OS/daemon. This list is separate from the File
// Explorer display filter (which only hides rows). Directories like `dist`
// and `build` remain visible in the tree but will not auto-refresh.
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
// ── Debounce helpers ─────────────────────────────────────────────────
@@ -36,23 +29,18 @@ const DEBOUNCE_TRAILING_MS = 150
const DEBOUNCE_MAX_WAIT_MS = 500
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot and WatcherSubscription are defined in filesystem-watcher-wsl.ts
// and re-used here so both native and WSL watchers share the same shape.
// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape.
// ── Module state ─────────────────────────────────────────────────────
const watchedRoots = new Map<string, WatchedRoot>()
// Why: roots that failed watcher creation (e.g. WSL UNC paths where
// @parcel/watcher's ReadDirectoryChangesW doesn't work) are cached so
// we don't retry on every worktree switch and spam the console with
// repeated "Failed to read changes" / "watchman not found" errors.
// Why: cache roots that failed watcher creation (e.g. WSL UNC paths) so we don't retry every worktree switch and spam the console with errors.
const UNWATCHABLE_ROOT_CACHE_MAX = 256
const unwatchableRoots = new Set<string>()
function rememberUnwatchableRoot(rootKey: string): void {
// Why: missing/deleted worktrees can churn through unique paths during a long
// session; keep retry suppression useful without retaining every failed path.
// Why: deleted worktrees churn through unique paths; cap the set so retry suppression stays useful without retaining every failed path.
unwatchableRoots.delete(rootKey)
unwatchableRoots.add(rootKey)
while (unwatchableRoots.size > UNWATCHABLE_ROOT_CACHE_MAX) {
@@ -64,28 +52,20 @@ function rememberUnwatchableRoot(rootKey: string): void {
}
}
// Why: watcher cleanup is keyed to the renderer WebContents, not to a specific
// watched root. One listener per sender avoids MaxListeners warnings when a
// workspace has many local and SSH-backed worktrees open.
// Why: key cleanup by sender WebContents (not per root) to avoid MaxListeners warnings when a workspace has many worktrees open.
const senderCleanupRegistered = new Set<number>()
// Why: on Windows, tearing down and recreating @parcel/watcher subscriptions
// is expensive (ReadDirectoryChangesW setup + antivirus scanning can take
// 500 ms+). A 30 s grace period lets rapid worktree switches reuse the
// existing watcher instead of paying the creation cost on every switch.
// Key: rootKey, Value: pending teardown timer.
// Why: recreating @parcel/watcher on Windows is expensive (ReadDirectoryChangesW + AV, 500ms+); a 30s grace lets rapid switches reuse the watcher.
const WATCHER_TEARDOWN_GRACE_MS = 30_000
const pendingTeardowns = new Map<string, ReturnType<typeof setTimeout>>()
// Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy
// cleanup can start it before app shutdown, so will-quit must still await it.
// Why: @parcel/watcher unsubscribe does native async work that sender-destroy can start before shutdown, so will-quit must still await it.
const pendingLocalUnsubscribes = new Set<Promise<void>>()
const pendingLocalUnsubscribesByRoot = new Map<string, Set<Promise<void>>>()
const suspendedLocalWatcherListeners = new Map<
string,
{ worktreePath: string; listeners: Map<number, WebContents> }
>()
// Why: an install cancelled by shutdown cannot be revived by a waiter that
// resumes after a later handler call reopens the watcher subsystem.
// Why: an install cancelled by shutdown can't be revived by a waiter that resumes after a later call reopens the subsystem.
let localWatchersClosed = false
let localWatcherLifecycleGeneration = 0
const failedLocalUnsubscribes = new Map<string, unknown>()
@@ -99,8 +79,7 @@ type LocalWatcherCapacityRetry = {
listeners: Map<number, WebContents>
cancelWait: () => void
}
// Why: native watcher creation is async. Concurrent local watch requests for
// the same root must share one install or later resolves can orphan listeners.
// Why: watcher creation is async; concurrent watch requests for the same root must share one install or later resolves orphan listeners.
const inFlightLocalInstalls = new Map<string, LocalWatcherInstallToken>()
const pendingLocalInstallPromises = new Map<string, Promise<LocalWatcherInstallResult>>()
const pendingLocalCapacityRetries = new Map<string, LocalWatcherCapacityRetry>()
@@ -122,8 +101,7 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void {
token.listeners.delete(senderId)
if (token.listeners.size === 0) {
token.cancelled = true
// Why: match closeLocalWatcherForWorktreePath / closeAllWatchers — abort
// so a pending native/forked subscription stops early, not at completion.
// Why: abort so a pending native/forked subscription stops early (matches closeLocalWatcherForWorktreePath / closeAllWatchers).
token.abortController.abort()
}
}
@@ -205,9 +183,7 @@ function normalizeRootPath(rootPath: string): string {
let resolved = isWindowsAbsolutePathLike(rootPath)
? path.win32.resolve(rootPath)
: path.resolve(rootPath)
// Why: on Windows, watcher events may report lowercase drive letters while
// stored worktree paths use uppercase. Normalizing here ensures the renderer's
// POSIX normalization produces casing-consistent results (see design §4.4).
// Why: Windows watcher events may use lowercase drive letters vs stored uppercase; normalize so renderer casing stays consistent (§4.4).
if (/^[a-zA-Z]:/.test(resolved)) {
resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1)
}
@@ -217,8 +193,7 @@ function normalizeRootPath(rootPath: string): string {
function localWatcherRoot(rootPath: string): { key: string; path: string } {
const normalizedPath = normalizeRootPath(rootPath)
return {
// Why: Windows drive and UNC paths are case-insensitive; destructive
// cleanup must find the owner even when Git returns a different spelling.
// Why: Windows drive/UNC paths are case-insensitive; cleanup must match the owner even when Git returns a different spelling.
key: normalizeRuntimePathForComparison(normalizedPath),
path: normalizedPath
}
@@ -233,10 +208,7 @@ function normalizeEventPath(eventPath: string): string {
}
// ── Event coalescing ─────────────────────────────────────────────────
// Why: within a single flush window the same path can appear multiple times.
// Keep the last event per path, except: delete→create emits both (the delete
// triggers subtree cleanup, the create triggers parent refresh); create→delete
// is dropped entirely (net no-op). See design §4.4.
// Why: keep the last event per path in a flush window; delete→create emits both (delete cleans the subtree, create refreshes the parent), create→delete is dropped (§4.4).
function coalesceEvents(
raw: WatcherEvent[]
@@ -264,10 +236,7 @@ function coalesceEvents(
lastByPath.set(p, { type: evt.type, index: i })
// Why: if a later event (e.g. update) supersedes a delete→create sequence,
// the stale delete must be dropped. Otherwise the final output would include
// a spurious delete + the new event type (e.g. delete→create→update would
// emit delete+update, but the file exists so the delete is wrong). See §4.4.
// Why: drop the stale delete when a later event supersedes delete→create, else output has a spurious delete for a file that still exists (§4.4).
if (evt.type !== 'create' && deleteBeforeCreate.has(p)) {
deleteBeforeCreate.delete(p)
}
@@ -295,9 +264,7 @@ async function tryStatIsDirectory(filePath: string): Promise<boolean | undefined
const s = await stat(filePath)
return s.isDirectory()
} catch {
// Why: if stat fails (EPERM, vanished temp file), return undefined.
// The renderer treats undefined the same as a file event (parent-only
// invalidation), which is the safe default. See design §4.4.
// Why: stat failure (EPERM, vanished file) → undefined; renderer treats it as a file event, the safe default (§4.4).
return undefined
}
}
@@ -329,20 +296,16 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
}
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: deletion storms can be valid but too large to coalesce/stat/send
// per path. One overflow asks the renderer for the same conservative refresh.
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
emitOverflowPayload(rootKey, root)
return
}
const coalesced = coalesceEvents(rawEvents)
// Build the payload with isDirectory info
const events: FsChangeEvent[] = await Promise.all(
coalesced.map(async (evt) => {
// Why: for delete events the path no longer exists on disk, so stat is
// not possible. Set isDirectory to undefined and let the renderer infer
// from dirCache (if the deleted path is a dirCache key, it's a directory).
// Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache.
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
return {
@@ -403,17 +366,12 @@ async function createWatcher(
}
try {
// Why: track whether the error callback already ran cleanup before
// subscribe() resolved. If it did, the subscription object returned
// by subscribe() would be orphaned (never stored in watchedRoots and
// therefore never unsubscribed), leaking a native file-watcher handle.
// Why: if the error callback cleaned up before subscribe() resolved, its returned subscription is orphaned and leaks a native handle.
let errorCleanedUp = false
const watcherOptions = {
...buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS),
// Why: Parcel checks Watchman before the native Windows backend by
// default, and Windows prints a shell-level "watchman not recognized"
// error for that probe. Pinning the backend keeps local watches quiet.
// Why: Parcel probes Watchman first, which prints a shell-level "watchman not recognized" error on Windows; pin the backend to suppress it.
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
}
@@ -422,30 +380,19 @@ async function createWatcher(
scheduleBatchFlush(rootKey, root)
}
// Why: subscriptions run in a forked watcher process (issue #7547 —
// watcher.node teardown races fail-fast the hosting process). A watcher
// crash there is recovered by resubscribing; onInterruption marks the
// batch overflowed so the renderer refreshes past the event gap.
// Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap.
root.subscription = await subscribeViaWatcherProcess(
rootPath,
(err, events) => {
if (err) {
// Why: watcher errors (including watched-root deletion) are treated
// as overflow so the renderer conservatively refreshes all visible
// tree state rather than trusting possibly-invalid caches (§7.2, §7.3).
// Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3).
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
emitOverflowPayload(rootKey, root)
// Why: after a watcher error the native subscription may be invalid
// (e.g. watched root was deleted). Tear down the dead watcher so we
// don't leave a dangling subscription for a root that no longer
// exists on disk (§7.3).
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
// Why: the error callback can fire before `watcher.subscribe()`
// resolves and assigns root.subscription (e.g. the watched root
// is deleted or inaccessible at startup). Guard against null so
// the cleanup path doesn't crash the main process.
// Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash.
if (root.subscription) {
retainLocalWatcherPhysicalFailure(rootKey, err)
void trackLocalUnsubscribe(rootKey, root)
@@ -461,25 +408,20 @@ async function createWatcher(
watcherOptions,
{
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
// A child restart or bounded-queue overflow loses path precision; both
// require the same conservative renderer refresh.
// A child restart or bounded-queue overflow loses path precision; both need the same conservative renderer refresh.
onInterruption: markWatcherInterrupted,
onOverflow: markWatcherInterrupted,
signal
}
)
// Why: if the error callback already fired and cleaned up watchedRoots
// before subscribe() resolved, the subscription we just received is
// orphaned. Unsubscribe it immediately to avoid leaking a native
// file-watcher handle that no code path would ever clean up.
// Why: error callback already cleaned up watchedRoots before subscribe() resolved; unsubscribe this orphaned subscription so it doesn't leak.
if (errorCleanedUp) {
void trackLocalUnsubscribe(rootKey, root)
throw new Error(`Watcher for ${rootKey} errored during subscribe`)
}
} catch (err) {
// Why: if the watcher backend throws synchronously on a deleted root
// or permission error, log rather than crashing the main process (§7.3).
// Why: watcher backend can throw synchronously on a deleted root/permission error; log rather than crash the main process (§7.3).
console.error(`[filesystem-watcher] failed to subscribe ${rootKey}:`, err)
throw err
}
@@ -531,8 +473,7 @@ function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise<void
})
pendingLocalUnsubscribes.add(unsubscribePromise)
rootUnsubscribes.add(unsubscribePromise)
// Why: background cleanup must not create unhandled rejections, but the
// original promise stays rejected so later destructive cleanup can fail closed.
// Why: swallow here to avoid unhandled rejections, but keep the original promise rejected so later destructive cleanup can fail closed.
void unsubscribePromise.catch((error: unknown) => {
retainLocalWatcherPhysicalFailure(rootKey, error)
console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, error)
@@ -631,8 +572,7 @@ async function subscribeWhileRemovalAllowed(
const inFlight = inFlightLocalInstalls.get(rootKey)
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
if (canJoinInstall) {
// Why: an unwatch may cancel an install while another renderer is still
// awaiting the same root; a new live listener should keep it alive.
// Why: an unwatch may cancel an install while another renderer awaits the same root; a new live listener keeps it alive.
addInFlightLocalInstallListener(inFlight, sender)
for (const listener of capacityRetryListeners) {
addInFlightLocalInstallListener(inFlight, listener)
@@ -645,8 +585,7 @@ async function subscribeWhileRemovalAllowed(
!localWatchersClosed &&
generation === localWatcherLifecycleGeneration
) {
// Why: AbortSignal cannot be revived. Listeners arriving after physical
// cancellation wait out that generation, then own a fresh install.
// Why: AbortSignal can't be revived; listeners arriving after cancellation wait out that generation, then own a fresh install.
if (pendingLocalInstallPromises.get(rootKey) === pendingInstall) {
pendingLocalInstallPromises.delete(rootKey)
}
@@ -727,8 +666,7 @@ async function doInstallLocalWatcher(
}
try {
// Why: WSL paths use one snapshot subprocess inside the Linux distro so
// `wsl --shutdown` can kill it; native Windows paths use @parcel/watcher.
// Why: WSL paths use one snapshot subprocess inside the distro so `wsl --shutdown` can kill it; native Windows uses @parcel/watcher.
root = isWslPath(worktreePath)
? await createWslWatcher(
rootKey,
@@ -742,8 +680,7 @@ async function doInstallLocalWatcher(
)
: await createWatcher(rootKey, rootPath, cancelToken.abortController.signal)
} catch (error) {
// Why: setup may fail after its child misses the exit deadline; retain that
// owner even when the ordinary renderer-facing setup error is swallowed.
// Why: setup can fail after its child misses the exit deadline; retain that owner even when the renderer-facing error is swallowed.
retainLocalWatcherPhysicalFailure(rootKey, error)
if (cancelToken.cancelled) {
if (isWatcherProcessFailure(error) && error.code === 'process_unavailable') {
@@ -751,8 +688,7 @@ async function doInstallLocalWatcher(
}
return 'cancelled'
}
// Why: capacity is transient; once another physical child exits this root
// must be allowed to retry instead of entering the permanent-failure cache.
// Why: capacity is transient — allow retry once another child exits instead of caching this root as permanently failed.
if (error instanceof WatcherChildCapacityError) {
scheduleLocalCapacityRetry(rootKey, worktreePath, cancelToken.listeners)
return 'capacity'
@@ -802,8 +738,7 @@ function unsubscribe(worktreePath: string, senderId: number): void {
if (inFlight) {
inFlight.listeners.delete(senderId)
inFlight.cancelled = inFlight.listeners.size === 0
// Why: same early-cancel as closeLocalWatcherForWorktreePath — last normal
// disconnect must abort the pending native/forked install, not let it finish.
// Why: last normal disconnect must abort the pending native/forked install (same early-cancel as closeLocalWatcherForWorktreePath).
if (inFlight.cancelled) {
inFlight.abortController.abort()
}
@@ -816,15 +751,13 @@ function unsubscribe(worktreePath: string, senderId: number): void {
root.listeners.delete(senderId)
// Defer watcher teardown when the last subscriber leaves so rapid
// worktree switches can reuse the existing native watcher.
// Defer teardown when the last subscriber leaves so rapid worktree switches reuse the native watcher.
if (root.listeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
// Why: duplicate renderer cleanup can call unwatch more than once for a
// root; keep one tracked grace timer instead of leaking overwritten timers.
// Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one.
if (pendingTeardowns.has(rootKey)) {
return
}
@@ -873,8 +806,7 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
const inFlight = inFlightLocalInstalls.get(rootKey)
if (inFlight) {
// Why: Windows keeps watched directories locked; deletion must be able to
// cancel an in-flight subscription before Git tries to remove the tree.
// Why: Windows locks watched directories; deletion must cancel an in-flight subscription before Git removes the tree.
inFlight.listeners.clear()
inFlight.cancelled = true
inFlight.abortController.abort()
@@ -963,22 +895,13 @@ const pendingRemoteWatcherRetryListeners = new Map<
string,
{ listeners: Map<number, WebContents>; startedAt: number }
>()
// Why: last-listener cleanup aborts relay setup, while late success is still
// unwatched instead of being installed after the renderer stopped watching.
// Why: last-listener cleanup aborts relay setup; late success is unwatched rather than installed after the renderer stopped watching.
const inFlightRemoteInstalls = new Map<string, RemoteWatcherInstallToken>()
// Why: dedupe concurrent installRemoteWatcher calls for the same key so
// overlapping fs:watchWorktree IPCs share one native watcher and one listener
// map, instead of each call independently invoking provider.watch() and
// overwriting the per-key state on resolution.
// Why: dedupe concurrent installRemoteWatcher calls per key so overlapping watches share one watcher instead of clobbering per-key state.
const pendingRemoteInstallPromises = new Map<string, Promise<RemoteWatcherInstallResult>>()
// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner
// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing
// the token-abort loop. A genuine new fs:watchWorktree clears the latch.
// Why: block installs beginning after closeAllWatchers (joiner recursion / retry tick bypass the abort loop); a new fs:watchWorktree clears it.
let remoteWatchersClosed = false
// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a
// fresh call once a genuine new watch reopens the subsystem. Each call captures
// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited
// across a shutdown+reopen recurses on a stale generation and is refused.
// Why: closeAllWatchers bumps this so a joiner that awaited across shutdown+reopen is refused (the latch alone can't tell it from a fresh call).
let remoteWatcherLifecycleGeneration = 0
const REMOTE_WATCH_RETRY_MS = 1_000
const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000
@@ -1077,8 +1000,7 @@ function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken):
return
}
token.abortScheduled = true
// Why: a replacement sender can synchronously revive the shared install
// during a renderer handoff; otherwise stop the relay crawl next microtask.
// Why: a replacement sender can synchronously revive the shared install during a renderer handoff; otherwise stop the relay crawl next microtask.
queueMicrotask(() => {
token.abortScheduled = false
if (token.cancelled && token.listeners.size === 0) {
@@ -1148,10 +1070,7 @@ async function installRemoteWatcher(
worktreePath: string,
generation = remoteWatcherLifecycleGeneration
): Promise<RemoteWatcherInstallResult> {
// Why: refuse installs racing in after teardown (joiner recursion, fired retry
// tick) so provider.watch() is never called and registered post-shutdown. The
// generation guard also refuses a waiter that captured an earlier lifecycle,
// even after a new watch reopened the subsystem.
// Why: refuse installs racing in after teardown (or a waiter from an earlier lifecycle) so provider.watch() isn't called post-shutdown.
if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) {
return 'cancelled'
}
@@ -1185,19 +1104,13 @@ async function installRemoteWatcherWhileRemovalAllowed(
addRemoteWatchListener(key, sender)
return 'installed'
}
// Why: a second concurrent fs:watchWorktree for the same key must share the
// first call's provider.watch() instead of starting its own. Without this,
// both calls would create distinct native watchers and the second's resolve
// would overwrite the per-key state, dropping the first's unwatch handle
// and erasing its sender from the listener map.
// Why: concurrent same-key watches must share the first provider.watch(); separate watchers would clobber per-key state and drop the unwatch handle.
const pendingInstall = pendingRemoteInstallPromises.get(key)
if (pendingInstall) {
const inFlight = inFlightRemoteInstalls.get(key)
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
if (canJoinInstall) {
// Why: a new watcher can join after all previous pending listeners
// unwatched but before provider.watch() resolves; revive that install
// instead of inheriting the stale cancellation.
// Why: a new watcher joining before provider.watch() resolves should revive the install instead of inheriting the stale cancellation.
addInFlightRemoteInstallListener(inFlight, sender)
}
const result = await pendingInstall
@@ -1215,8 +1128,7 @@ async function installRemoteWatcherWhileRemovalAllowed(
!sender.isDestroyed() &&
generation === remoteWatcherLifecycleGeneration
) {
// Why: AbortSignal cannot be revived. A listener arriving after physical
// cancellation waits out that generation, then owns a fresh install.
// Why: AbortSignal can't be revived; a listener arriving after cancellation waits out that generation, then owns a fresh install.
if (pendingRemoteInstallPromises.get(key) === pendingInstall) {
pendingRemoteInstallPromises.delete(key)
}
@@ -1358,10 +1270,7 @@ function scheduleRemoteWatcherRetry(
pendingRemoteWatcherRetries.delete(key)
pendingRemoteWatcherRetryListeners.delete(key)
loggedUnavailableRemoteWatchers.delete(key)
// Why: the original `fs:watchWorktree` handler resolved successfully
// when the retry was first scheduled, so the renderer believes the
// watch is live. After giving up, emit a one-shot overflow so the
// renderer falls back to a manual refresh instead of waiting forever.
// Why: handler already resolved so the renderer thinks the watch is live; emit overflow to force a manual refresh instead of waiting forever.
for (const listener of retry.listeners.values()) {
if (listener.isDestroyed()) {
continue
@@ -1387,10 +1296,7 @@ function scheduleRemoteWatcherRetry(
listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath))
)
.then((results) => {
// Why: 'cancelled' means an unwatch/shutdown raced with this install
// attempt. Re-arming the retry would reschedule for a worktree the
// renderer explicitly stopped watching, eventually firing a stale
// overflow when the 60s window expires.
// Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires.
if (results.some((result) => result === 'unavailable')) {
for (const listener of listeners) {
scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt)
@@ -1416,8 +1322,7 @@ export function registerFilesystemWatcherHandlers(): void {
'fs:watchWorktree',
async (event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
if (args.connectionId) {
// Why: a real new watch reopens the subsystem after closeAllWatchers
// latched it shut (also how tests reset between cases).
// Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases).
remoteWatchersClosed = false
const key = remoteWatcherKey(args.connectionId, args.worktreePath)
const result = await installRemoteWatcher(
@@ -1437,8 +1342,7 @@ export function registerFilesystemWatcherHandlers(): void {
}
return
}
// Why: tests and post-shutdown renderer reattachment reopen the local
// subsystem, while stale callers retain the prior generation.
// Why: reopen the local subsystem for tests and post-shutdown reattachment; stale callers keep the prior generation.
localWatchersClosed = false
await subscribe(args.worktreePath, event.sender)
}
@@ -1462,10 +1366,7 @@ export function registerFilesystemWatcherHandlers(): void {
pendingRemoteWatcherRetries.delete(key)
pendingRemoteWatcherRetryListeners.delete(key)
}
// Why: a `provider.watch()` call may still be in flight from a
// retry tick. Mark it cancelled so installRemoteWatcher discards
// the unwatch handle when the promise finally resolves, instead
// of leaving the renderer with a watcher it asked to stop.
// Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded.
const inFlight = inFlightRemoteInstalls.get(key)
if (inFlight) {
inFlight.listeners.delete(_event.sender.id)
@@ -1508,15 +1409,13 @@ export async function closeAllWatchers(): Promise<void> {
pendingRemoteWatcherRetries.clear()
pendingRemoteWatcherRetryListeners.clear()
loggedUnavailableRemoteWatchers.clear()
// Why: latch both watcher subsystems shut so late installs cannot register
// post-shutdown. Generation bumps also reject waiters from an older lifecycle.
// Why: latch both subsystems shut so late installs can't register; generation bumps reject older-lifecycle waiters.
remoteWatchersClosed = true
remoteWatcherLifecycleGeneration += 1
localWatchersClosed = true
localWatcherLifecycleGeneration += 1
pendingRemoteInstallPromises.clear()
// Why: cancel any in-flight provider.watch() calls so their resolved
// unwatch handles are discarded instead of being installed after shutdown.
// Why: cancel in-flight provider.watch() calls so their resolved unwatch handles aren't installed post-shutdown.
for (const token of inFlightRemoteInstalls.values()) {
token.listeners.clear()
token.cancelled = true
@@ -1537,15 +1436,10 @@ export async function closeAllWatchers(): Promise<void> {
watchedRoots.clear()
await Promise.allSettled(Array.from(pendingLocalUnsubscribes))
failedLocalUnsubscribes.clear()
// Why: with every local subscription released, drop the forked watcher
// process outright — process death frees any remaining native handles
// without running watcher.node's crash-prone async teardown in this process.
// Why: kill the forked watcher process instead of watcher.node's crash-prone async teardown; process death frees native handles.
disposeWatcherProcess()
// Why: remote watchers are tracked separately from local @parcel/watcher
// subscriptions. Without cleaning them up here, their unwatch callbacks
// would never fire, leaving the relay polling for FS changes after the
// app has shut down.
// Why: remote watchers are separate from local @parcel/watcher subs; unwatch here or the relay keeps polling FS after shutdown.
for (const [key, state] of remoteWatchers) {
try {
state.unwatch()
+27 -83
View File
@@ -126,18 +126,11 @@ import { sanitizeLocalDownloadFilename } from '../local-download-filename'
import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder'
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes
// ordinary JSON/log files inaccessible before the editor can degrade features.
// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files.
const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB
const BINARY_PROBE_BYTES = 8192
const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/
// Why: previewable binaries (PDFs, images) are rendered by the viewer as
// base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and
// raising this cap only affects binary preview, not text/search paths.
// The relay (SSH) uses a smaller 10MB cap because its JSON-RPC frames are
// bounded by MAX_MESSAGE_SIZE = 16MB; the local IPC path has no such limit,
// so 50MB covers real-world PDFs (specs, datasheets, image-heavy contracts).
// See src/relay/fs-handler-utils.ts for the remote-side reasoning.
// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe.
const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB
const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
@@ -210,8 +203,7 @@ type DownloadSession = {
const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
// Why: promotion uses rename/no-clobber operations that must stay on the
// destination volume, so transfer paths intentionally remain siblings.
// Why: promotion renames must stay on the destination volume, so transfer paths remain siblings.
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
}
@@ -388,8 +380,7 @@ async function getRepoForSourceControlAi(
if (repo.connectionId !== args.connectionId) {
return null
}
// Why: a single SSH connection can host several repos; repo-scoped AI
// overrides only apply when the requested worktree belongs to that repo.
// Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo.
return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId))
? repo
: null
@@ -397,8 +388,7 @@ async function getRepoForSourceControlAi(
if (repo.connectionId) {
return null
}
// Why: renderer-supplied repoId is advisory; only apply repo overrides when
// the requested local worktree is known to belong to that repo.
// Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo.
return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null
}
@@ -459,9 +449,7 @@ async function isDirectoryEntry(
entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean },
_resolveEntryPath: (entryPath: string) => Promise<string>
): Promise<boolean> {
// Why: following a symlink just to decorate readDir can touch macOS
// TCC-protected app containers. Treat links as file-like until the user
// explicitly opens them.
// Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened.
void _resolveEntryPath
if (entry.isSymbolicLink()) {
void dirPath
@@ -509,10 +497,7 @@ export function registerFilesystemHandlers(
ipcMain.handle(
'fs:readDir',
async (_event, args: { dirPath: string; connectionId?: string }): Promise<DirEntry[]> => {
// Why: a thrown fs:readDir reaches the renderer as the opaque "Error
// invoking remote method 'fs:readDir'" (Windows WSL/UNC realpath/readdir
// failures, dropped SSH providers). Record which throw site fired plus a
// redacted path shape so these are diagnosable without the raw path.
// Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable.
let throwSite: ReadDirThrowSite = 'authorize'
try {
if (args.connectionId) {
@@ -588,17 +573,13 @@ export function registerFilesystemHandlers(
return {
content: buffer.toString('base64'),
isBinary: true,
// Why: the renderer/store contract already keys previewable binary
// rendering off `isImage`. Keep that legacy flag for PDFs too so the
// new preview path stays compatible with existing callers.
// Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible.
isImage: true,
mimeType
}
}
// Why: the text cap is intentionally larger than the old binary cap.
// Probe unknown large files first so archives do not get fully buffered
// just to discover they are not editable text.
// Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text.
if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) {
return { content: '', isBinary: true }
}
@@ -859,25 +840,17 @@ export function registerFilesystemHandlers(
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.deletePath(args.targetPath, args.recursive)
}
// Why: deleting must operate on the symlink itself, not its target.
// Following the link with realpath() would trash the real file — which
// could be another file inside the worktree, or a path outside all
// allowed roots that we would never be able to delete again.
// Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots).
const targetPath = await resolveAuthorizedPath(args.targetPath, store, {
preserveSymlink: true
})
// Why: WSL UNC targets (\\wsl.localhost\<distro>\...) have no Recycle Bin,
// so shell.trashItem throws. Hard-delete via `rm` inside the distro instead
// (true delete, honors Linux perms). Returns false for normal local paths,
// which still go to the Recycle Bin (issue #6415).
// Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415).
if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) {
return
}
// Why: once auto-refresh exists, an external delete can race with a
// UI-initiated delete. Swallowing ENOENT keeps the action idempotent
// from the user's perspective (design §7.1).
// Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1).
try {
await shell.trashItem(targetPath)
} catch (error) {
@@ -957,10 +930,7 @@ export function registerFilesystemHandlers(
)
const searchKey = `${event.sender.id}:${rootPath}`
// Why: checking rg availability upfront avoids a race condition where
// spawn('rg') emits 'close' before 'error' on some platforms, causing
// the handler to resolve with empty results before the git-grep
// fallback can run. The result is cached after the first check.
// Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs.
const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
return searchWithGitGrep(rootPath, args, maxResults, localGitOptions)
@@ -969,11 +939,7 @@ export function registerFilesystemHandlers(
return new Promise((resolvePromise) => {
const rgArgs = buildRgArgs(args.query, rootPath, args)
// Why: search requests are fired on each query/options change. If the
// previous ripgrep process keeps running, it can continue streaming and
// parsing thousands of matches on the Electron main thread after the UI
// no longer cares about that result, which is exactly the freeze users
// experience in large repos.
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
activeTextSearches.get(searchKey)?.kill()
const acc = createAccumulator()
@@ -982,8 +948,7 @@ export function registerFilesystemHandlers(
let child: ChildProcess | null = null
let killTimeout: ReturnType<typeof setTimeout>
// Why: WSL-routed rg emits Linux-native paths. UNC repos carry their
// distro in the path; Windows-path repos carry it in project runtime.
// Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime.
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
const transformAbsPath = wslDistroForOutput
? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p)
@@ -998,8 +963,7 @@ export function registerFilesystemHandlers(
activeTextSearches.delete(searchKey)
}
clearTimeout(killTimeout)
// Why: child.kill() is advisory. If rg ignores it, detach our
// closures so repeated local searches do not retain old scans.
// Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it.
child?.stdout?.off('data', handleStdoutData)
child?.stderr?.off('data', handleStderrData)
child?.off('error', handleError)
@@ -1049,8 +1013,7 @@ export function registerFilesystemHandlers(
nextChild.once('error', handleError)
nextChild.once('close', handleClose)
// Why: if the timeout fires, the child is killed and results are partial.
// We must mark them as truncated so the UI can indicate incomplete results.
// Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results.
killTimeout = setTimeout(() => {
acc.truncated = true
child?.kill()
@@ -1061,9 +1024,7 @@ export function registerFilesystemHandlers(
)
// ─── List all files (for quick-open) ─────────────────────
// Why #7721: keyed by renderer-generated token so a workspace switch can
// abort the previous workspace's full-tree scan (SSH relays otherwise stack
// scans that starve interactive fs.readDir/fs.stat past their 30s timeout).
// Why #7721: token-keyed so a workspace switch aborts the prior full-tree scan (SSH otherwise stacks scans past the 30s timeout).
const listFilesCancellations = createSenderScopedRequestCancellations()
ipcMain.handle(
'fs:listFiles',
@@ -1080,16 +1041,11 @@ export function registerFilesystemHandlers(
try {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
// Why: when the SSH connection is not yet established (cold start) or
// temporarily disconnected, return [] so quick-open shows "No matching
// files" instead of an error banner. The file list will repopulate when
// the user re-opens quick-open after the connection is restored.
// Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error.
if (!provider) {
return []
}
// Why: forward excludePaths through to the remote provider.
// Dropping it here would silently double-scan nested linked worktrees
// over SSH and contribute to timeout-induced partial results.
// Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results.
return await provider.listFiles(args.rootPath, {
excludePaths: args.excludePaths,
signal: controller?.signal
@@ -1136,8 +1092,7 @@ export function registerFilesystemHandlers(
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
// Why: awaiting here keeps the cancellation token registered until
// the remote request settles instead of running finally immediately.
// Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it).
return await provider.getStatus(args.worktreePath, options)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
@@ -1157,9 +1112,7 @@ export function registerFilesystemHandlers(
gitStatusCancellations.cancel(event, args.requestToken)
})
// Why: the parent status only reports one gitlink row per submodule. When the
// user expands a dirty submodule, this fetches the inner per-file changes by
// running a plain status inside the submodule's own worktree (read-only).
// Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree.
ipcMain.handle(
'git:submoduleStatus',
async (
@@ -1216,10 +1169,7 @@ export function registerFilesystemHandlers(
}
)
// Why: when status hits the entry limit, the SCM view offers to .gitignore the
// folder that's flooding it. These two handlers back that flow. Local-only:
// the huge-untracked-folder case is a local-dev pathology, and routing a
// .gitignore write through the SSH provider isn't worth the surface here.
// Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology.
ipcMain.handle(
'git:findHugeFoldersToIgnore',
async (_event, args: { worktreePath: string }): Promise<string[]> => {
@@ -1265,9 +1215,7 @@ export function registerFilesystemHandlers(
}
)
// Why: lightweight fs-only check for conflict operation state. Used to poll
// non-active worktrees so their "Rebasing"/"Merging" badges clear when the
// operation finishes, without running a full `git status`.
// Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status.
ipcMain.handle(
'git:conflictOperation',
async (
@@ -1854,9 +1802,7 @@ export function registerFilesystemHandlers(
pushTarget?: GitPushTarget
}
): Promise<void> => {
// Why: coerce to strict boolean at the IPC boundary so a malformed
// renderer payload (e.g. string 'false') can't silently enable
// --set-upstream mode. Mirrors the relay handler in src/relay/git-handler.ts.
// Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts.
const publish = args.publish === true
if (args.connectionId) {
if (args.pushTarget) {
@@ -2227,8 +2173,7 @@ export function registerFilesystemHandlers(
_event,
args: { worktreePath: string; relativePath: string; line: number; connectionId?: string }
): Promise<string | null> => {
// Why: remote repos can't read relay-side .git/config locally. Delegate
// URL construction to the SSH provider, which can fetch remote metadata.
// Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider.
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
@@ -2248,8 +2193,7 @@ export function registerFilesystemHandlers(
args: { worktreePath: string; sha: string; connectionId?: string }
): Promise<string | null> => {
const sha = validateFullGitObjectId(args.sha, 'sha')
// Why: remote repos can't read relay-side .git/config locally. Delegate
// URL construction to the SSH provider, which can fetch remote metadata.
// Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider.
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
+36 -113
View File
@@ -58,11 +58,7 @@ const BUILT_IN_NOTIFICATION_SOUNDS: ReadonlyMap<string, string> = new Map([
])
type NotificationSoundId = NotificationSettings['customSoundId']
// Why: Electron Notification objects are normal JS objects — if the only
// reference is a local variable inside the ipcMain handler, the GC can
// collect them (and their click handlers) before the user interacts with
// the notification in macOS Notification Center. Prevent this by keeping a
// strong reference until the notification is clicked or closed.
// Why: keep a strong reference so GC can't collect notifications (and their click handlers) before the user interacts with them.
const activeNotifications = new Set<Notification>()
const activeNotificationsById = new Map<
string,
@@ -103,30 +99,18 @@ function retainNotificationUntilRelease(
const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000
const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000
// Why: Electron has no API to read macOS UNUserNotificationCenter
// authorization, so the freshest signal we have is what happened to the last
// notification we scheduled. Session-scoped on purpose: OS-level permission
// can change between runs, and a stale positive renders a false green card.
// Why: no API to read macOS auth, so track the last scheduled notification's outcome; session-scoped since permission can change between runs.
let lastObservedDeliveryOutcome: 'delivered' | 'failed' | null = null
let deliveryProbeInFlight: Promise<NotificationDeliveryProbeResult> | null = null
// Why: firing one probe notification is what instantiates Electron's
// presenter and pops the macOS permission dialog. Once per session is enough
// while the authorization readout reports the decision as pending.
// Why: firing one probe instantiates Electron's presenter and pops the macOS permission dialog; once per session is enough.
let permissionDialogTriggeredThisSession = false
/**
* Fallback signal for hosts without the native helper. Schedules a silent
* probe notification and reports whether macOS accepted it. 'failed' means
* the request was rejected (permission denied, or an unsigned build). On a
* fresh install the probe also instantiates Electron's notification
* presenter, which is what makes macOS pop the "Allow notifications?" dialog.
* Fallback for hosts without the native helper: schedules a silent probe and reports whether macOS accepted it.
* On a fresh install the probe also instantiates Electron's presenter, which pops the macOS permission dialog.
*
* Known ambiguity with no public API to resolve it (verified on macOS 26):
* while the dialog is unanswered and when notifications are toggled off in
* System Settings after being authorized macOS still accepts requests and
* silently swallows them, so 'delivered' can over-report. 'failed' fires for
* hard rejections (unsigned builds, dialog-level denial). The bundled
* notification-status helper exists precisely to avoid this ambiguity.
* Known ambiguity (verified macOS 26): while undecided, or when notifications are toggled off after being
* authorized, macOS silently swallows accepted requests, so 'delivered' can over-report; only 'failed' is definitive.
*/
function probeNotificationDelivery(): Promise<NotificationDeliveryProbeResult> {
if (deliveryProbeInFlight) {
@@ -167,9 +151,7 @@ function probeNotificationDelivery(): Promise<NotificationDeliveryProbeResult> {
function onShow(): void {
settle('delivered')
// Why: when delivery works the probe banner is visible, so it doubles
// as the user-facing confirmation — let it linger briefly instead of
// vanishing the instant it appears.
// Why: the probe banner doubles as the user-facing confirmation, so let it linger briefly instead of vanishing instantly.
const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS)
if (typeof closeTimer.unref === 'function') {
closeTimer.unref()
@@ -177,17 +159,14 @@ function probeNotificationDelivery(): Promise<NotificationDeliveryProbeResult> {
}
function onFailed(_event: unknown, _error?: string): void {
// Why: a rejected probe is an expected outcome (denied permission), not
// an anomaly — logging it would spam the console on every poll while
// the onboarding card waits for the user to allow notifications.
// Why: a rejected probe is expected (denied permission); don't log — it would spam the console on every poll.
settle('blocked')
releaseProbe()
}
probe.once('show', onShow)
probe.once('failed', onFailed)
// Why: don't record a 'failed' outcome on timeout — a missing callback is
// ambiguous, while the 'failed' event is a definitive rejection.
// Why: don't record 'failed' on timeout — a missing callback is ambiguous, only the 'failed' event is definitive.
timeoutTimer = setTimeout(() => {
if (!settled) {
settled = true
@@ -330,8 +309,7 @@ function reserveNotificationCooldown(
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const recentDesktopNotifications = new Map<string, number>()
const recentMobileNotifications = new Map<string, number>()
// Why: handler registration marks a fresh session permission evidence
// from a previous registration must not leak into the new one.
// Why: handler registration marks a fresh session; permission evidence from a previous one must not leak in.
lastObservedDeliveryOutcome = null
deliveryProbeInFlight = null
permissionDialogTriggeredThisSession = false
@@ -343,13 +321,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
openNotificationSystemSettings()
})
// Why: Electron's main-process `Notification` class exposes no synchronous
// way to read macOS auth status — the renderer-side `Notification.permission`
// does not exist here. We expose what we can reliably observe: whether the
// platform supports notifications and whether we've already kicked off the
// first-permission prompt. A 'denied' OS result is invisible to us; the
// dispatch path simply won't deliver in that case, which the user can
// diagnose via the System Settings deep-link.
// Why: Electron's main process can't read macOS auth status; expose only what we can observe (platform support + whether we've prompted).
const getPermissionStatus = (): NotificationPermissionStatusResult => ({
supported: Notification.isSupported(),
platform: process.platform,
@@ -360,20 +332,15 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
ipcMain.handle(
'notifications:probeDelivery',
async (_event, args?: { force?: boolean }): Promise<NotificationDeliveryProbeResult> => {
// Why: macOS-only. Windows/Linux have no equivalent first-use permission
// dialog, so the onboarding card that consumes this never renders there.
// Why: macOS-only Windows/Linux have no first-use permission dialog, so the onboarding card never renders there.
if (process.platform !== 'darwin' || !Notification.isSupported()) {
return { state: 'unsupported', authoritative: false }
}
// Why: probes (and the native helper's first-launch path) surface the
// macOS permission dialog — mark the one-shot startup registration as
// done so it can't fire a second prompt later.
// Why: probes surface the macOS permission dialog, so mark startup registration done to avoid a second prompt later.
if (store.getUI().notificationPermissionRequested !== true) {
store.updateUI({ notificationPermissionRequested: true })
}
// Preferred source: the bundled helper reads the real
// UNUserNotificationCenter authorization. Silent, so polling with it
// tracks System Settings changes live without flashing banners.
// Preferred source: the bundled helper reads real auth silently, so polling tracks System Settings changes without banners.
const authorization = await readNotificationAuthorizationStatus()
if (authorization === 'authorized') {
lastObservedDeliveryOutcome = 'delivered'
@@ -384,16 +351,13 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { state: 'blocked', authoritative: true }
}
if (authorization === 'not-determined') {
// Why: the dialog only appears once something asks fire a single
// probe per session to trigger it, then report the pending decision.
// Why: the dialog only appears once something asks; fire one probe per session to trigger it, then report pending.
if (!permissionDialogTriggeredThisSession) {
void probeNotificationDelivery()
}
return { state: 'awaiting-decision', authoritative: true }
}
// Helper unavailable ('unknown' status is also unusable evidence):
// fall back to scheduling-based probes with session caching, which
// avoids repeated probe banners when delivery works.
// Helper unavailable or 'unknown': fall back to scheduling-based probes with session caching to avoid repeated banners.
if (!args?.force && lastObservedDeliveryOutcome !== null) {
return {
state: lastObservedDeliveryOutcome === 'delivered' ? 'delivered' : 'blocked',
@@ -429,15 +393,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
_event,
args: NotificationDispatchRequest
): NotificationDispatchResult | Promise<NotificationDispatchResult> => {
// Why: a terminal bell or agent completion that arrives while the window
// is minimized/hidden lights the tray attention dot — a passive cue that
// clears on window show/restore (see index.ts). Placed before the
// focus-suppression, cooldown, and enabled gates below so those do not
// hold back the dot. It rides the notification dispatch, so it follows the
// renderer's per-source decision to notify: bells always reach here, while
// an agent completion is suppressed upstream when its notification is
// disabled. The status item exists on Windows and macOS, so
// setTrayAttention lights its attention dot there and no-ops on Linux.
// Why: light the tray attention dot before the cooldown/focus/enabled gates so they can't hold it back (clears on window show/restore; see index.ts).
if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') {
const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null
if (!isMainWindowVisible(activeWindow)) {
@@ -459,8 +415,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
const notificationOptions = buildNotificationOptions(args)
// Why: desktop focus only means this computer has the worktree visible;
// the paired phone may be locked or elsewhere and still needs the alert.
// Why: desktop focus only means this computer sees the worktree; the paired phone may still need the alert.
if (runtime && args.source !== 'test') {
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) {
@@ -486,11 +441,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { delivered: false, reason: 'suppressed-focus' }
}
// Why: the Settings test button is an explicit user action, often
// clicked repeatedly while tuning sounds, so it must bypass burst dedupe.
// Why: the Settings test button is an explicit, often-repeated user action, so it bypasses burst dedupe.
if (args.source !== 'test') {
// Dedupe by worktree, not by source — an agent finishing and a terminal bell
// often fire within the same data chunk so only the first one should surface.
// Dedupe by worktree, not source — agent-finish and terminal-bell often fire in one chunk; surface only the first.
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
if (!reserveNotificationCooldown(recentDesktopNotifications, dedupeKey, Date.now())) {
return { delivered: false, reason: 'cooldown' }
@@ -507,8 +460,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
// using the OS sound, ask Electron for the default notification sound.
// Why: macOS treats an unset sound as silent, so request Electron's default when using the OS sound.
notificationOptions.sound = 'default'
}
const notification = new Notification(notificationOptions)
@@ -520,8 +472,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
}
// Why: prevent GC from collecting the notification (and its click
// handler) while it's still visible in macOS Notification Center.
// Why: prevent GC from collecting the notification and its click handler while it's still visible.
let clickHandler: (() => void) | null = null
let failedHandler: ((_event: unknown, error?: string) => void) | null = null
const entryForId: { notification: Notification; release: () => void } | null =
@@ -548,24 +499,15 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
failedHandler = (_event, error) => {
// Why: Electron 42's macOS UNNotification backend reports unsigned
// apps and native delivery errors here; release immediately instead
// of retaining a dead notification until the fallback timer.
// Why: Electron 42's macOS backend reports unsigned/delivery failures here; release now, not after the fallback timer.
logNativeNotificationFailure(args.source, error)
// A definitive rejection — feeds the permission card's evidence.
// Why: feeds the permission card's evidence.
lastObservedDeliveryOutcome = 'failed'
release()
}
notification.on('failed', failedHandler)
// Why: clicking a notification should bring Orca to the foreground and
// switch to the worktree/pane that triggered it. Worktree activation owns
// repo/sidebar state; the optional focusTerminal follow-up uses the stable
// pane leaf id so split-pane notifications land on the exact pane.
// Why: worktreeId is formatted as "repoId::worktreePath". If the
// separator is missing we cannot reliably extract a repoId, so skip
// the click-to-navigate binding — the notification still fires but
// clicking it will not attempt to switch to an unknown worktree.
// Why: worktreeId is formatted "repoId::worktreePath"; without the separator we can't extract a repoId, so skip the click-to-navigate binding.
if (args.worktreeId && args.worktreeId.includes('::')) {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
clickHandler = () => {
@@ -585,6 +527,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
repoId,
worktreeId: args.worktreeId
})
// Why: focusTerminal targets the pane by stable leafId so split-pane notifications land on the exact pane.
const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null
if (paneTarget) {
win.webContents.send('ui:focusTerminal', {
@@ -622,12 +565,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
if (process.platform !== 'darwin') {
return deliverNativeNotification()
}
// Why: macOS silently swallows accepted notifications while permission
// is denied or the permission dialog is unanswered (verified on macOS
// 26). Skip the doomed native notification and tell the caller, so the
// renderer can surface an in-app fallback pointing at System Settings.
// The mobile dispatch above is unaffected — paired devices have their
// own notification channel.
// Why: macOS silently swallows notifications while permission is denied/undecided (verified macOS 26); skip so the renderer can show a fallback.
return readNotificationAuthorizationStatus().then((authorization) => {
if (authorization === 'denied' || authorization === 'not-determined') {
lastObservedDeliveryOutcome = 'failed'
@@ -638,10 +576,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
)
// Why: the preload caches the decoded blob keyed by path. Returning just
// the validated path lets it skip the 10MB IPC round-trip on every dispatch
// when the user's selection hasn't changed — terminal-bell bursts can fire
// many notifications in seconds.
// Why: return the path so the preload's path-keyed cache skips the 10MB IPC round-trip on repeat dispatches.
ipcMain.removeHandler('notifications:resolveSoundPath')
ipcMain.handle(
'notifications:resolveSoundPath',
@@ -692,20 +627,15 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
/**
* On first launch, when macOS notification permission is 'not-determined',
* show a welcome notification to trigger the system permission dialog.
* On first launch (macOS permission 'not-determined'), show a welcome notification to trigger the system prompt.
*
* Why: macOS requires at least one notification attempt before the system
* will prompt the user to allow/deny. Doing this at startup with meaningful
* content avoids a confusing blank notification later. The notification is
* closed shortly after to avoid lingering in Notification Center.
* Why: macOS requires at least one notification attempt before it will prompt to allow/deny.
*/
export function triggerStartupNotificationRegistration(store: Store): void {
if (process.platform !== 'darwin' || !Notification.isSupported()) {
return
}
// Why: only fire once per install not on every launch where status stays
// not-determined (e.g. if the user dismisses the macOS dialog without choosing).
// Why: fire once per install, not on every launch where status stays not-determined (e.g. user dismisses the dialog).
const ui = store.getUI()
if (ui.notificationPermissionRequested) {
return
@@ -717,8 +647,7 @@ export function triggerStartupNotificationRegistration(store: Store): void {
body: 'Allow notifications so Orca can alert you when agents finish or terminals need attention.'
})
// Why: prevent GC from collecting the notification (and its click handler)
// while it's still visible in macOS Notification Center.
// Why: prevent GC from collecting the notification and its click handler while it's still visible.
activeNotifications.add(notification)
let handled = false
@@ -749,19 +678,14 @@ export function triggerStartupNotificationRegistration(store: Store): void {
notification.close()
}
// Why: clicking the startup notification should take the user to macOS
// Notification Settings so they can verify/enable notifications for Orca.
// Without this, the notification reads like an actionable prompt ("Allow
// notifications…") but clicking it does nothing, which is confusing.
// Why: the body reads like an actionable "Allow notifications…" prompt, so clicking opens macOS Notification Settings.
function onClick(): void {
cleanup()
openNotificationSystemSettings()
}
function onShow(): void {
// Why: close after a short delay so the notification doesn't linger in
// Notification Center. The macOS permission dialog is a system-level sheet
// that appears independently and is not dismissed by closing this notification.
// Why: close after a delay so the banner doesn't linger; the macOS permission sheet is separate and unaffected.
closeTimer = setTimeout(cleanup, 8000)
if (typeof closeTimer.unref === 'function') {
closeTimer.unref()
@@ -769,8 +693,7 @@ export function triggerStartupNotificationRegistration(store: Store): void {
}
function onFailed(_event: unknown, error?: string): void {
// Why: Electron 42 requires code-signed macOS apps for UNNotification
// delivery. Unsigned builds fail here instead of producing the permission UI.
// Why: Electron 42 requires code-signed macOS apps for UNNotification delivery; unsigned builds fail here.
logNativeNotificationFailure('startup registration', error)
lastObservedDeliveryOutcome = 'failed'
cleanup()
+25 -77
View File
@@ -14,9 +14,7 @@ import {
type ResolvedPetManifest
} from './pet-bundle'
// Why: image-only pet uploads. Static + animated variants render natively
// via <img>, so no 3D engine is needed. Main owns the accepted-format table as
// the single source of truth for what the renderer will try to display.
// Why: pets are image-only — render natively via <img> (no 3D engine); main owns this format allowlist.
const IMAGE_FORMATS: Record<string, string> = {
'.png': 'image/png',
'.apng': 'image/apng',
@@ -36,9 +34,7 @@ function classifyFile(src: string): { mimeType: string; ext: string } | null {
return { mimeType: mime, ext }
}
// Why: keep using the legacy sidekicks folder so existing user-uploaded pets
// keep rendering after the product rename. The renderer only knows CustomPet.id;
// main resolves it to an absolute path inside this folder.
// Why: keep the legacy `sidekicks` folder so existing user-uploaded pets keep rendering after the product rename.
function getPetsDir(): string {
return join(app.getPath('userData'), 'sidekicks', 'custom')
}
@@ -47,9 +43,7 @@ const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user c
const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest.
function isSafeId(id: string): boolean {
// UUIDs only; blocks path traversal and unexpected characters. Storage ids
// are always generated in main (never sourced from the bundle's manifest.id),
// so this regex is the canonical gate for any fs path that includes the id.
// UUIDs only — canonical path-traversal gate; storage ids are always main-generated, never from manifest.id.
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
}
@@ -61,9 +55,7 @@ function resolvePetFile(id: string, fileName: string, kind: 'image' | 'bundle'):
const root = normalize(getPetsDir())
let filePath: string
if (kind === 'bundle') {
// Bundle layout: custom/<id>/<fileName>. fileName is just the spritesheet
// basename (e.g. "spritesheet.webp"); pet.json is read by main only and
// never served to the renderer.
// Bundle layout custom/<id>/<fileName>; fileName is the spritesheet basename — pet.json is main-only, never served.
filePath = normalize(join(root, id, safeName))
const bundleDir = normalize(join(root, id)) + sep
if (!filePath.startsWith(bundleDir)) {
@@ -71,8 +63,7 @@ function resolvePetFile(id: string, fileName: string, kind: 'image' | 'bundle'):
}
return filePath
}
// Legacy image layout: custom/<id>.<ext>. Filename must start with the id
// so the prefix check catches any edge case that slipped the regex.
// Legacy image layout custom/<id>.<ext>; filename must start with the id so the prefix check backstops the regex.
if (!safeName.startsWith(`${id}.`)) {
return null
}
@@ -92,8 +83,7 @@ const PetManifestSchema = z
.string()
.min(1)
.max(255)
// Why: belt-and-suspenders against malicious manifests — downstream
// resolve+prefix check still runs as defense in depth.
// Why: belt-and-suspenders vs malicious manifests — downstream resolve+prefix check still runs as defense in depth.
.refine(
(p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'),
'invalid spritesheetPath'
@@ -119,16 +109,12 @@ const PetManifestSchema = z
)
.optional()
})
// Why: tolerate unknown top-level fields. Pet-bundle generators may emit
// descriptive metadata (e.g. "kind": "person") we don't consume yet, and
// strict mode would reject those imports with a confusing "unrecognized
// key" error instead of just ignoring the extras.
// Why: .loose() ignores unknown manifest fields — generators emit metadata we don't consume; strict would reject imports.
.loose()
type PetManifest = z.infer<typeof PetManifestSchema> & PetManifestLike
// Why: renderer-supplied IPC inputs are untrusted — validate shape before any
// path resolution. resolvePetFile still gates the actual filesystem path.
// Why: renderer IPC inputs are untrusted — validate shape here; resolvePetFile still gates the actual filesystem path.
const PetFileRequestSchema = z.object({
id: z.string(),
fileName: z.string(),
@@ -138,17 +124,13 @@ const PetFileRequestSchema = z.object({
async function readSheetDimensions(
buffer: Buffer
): Promise<{ width: number; height: number } | null> {
// Why: Electron's nativeImage can fail to decode some valid WebP variants
// even though Chromium can render them. Sprite sheets only need the canvas
// size, so read WebP dimensions from the container header before falling
// back to native decoding.
// Why: nativeImage can fail on some valid WebP that Chromium renders — read WebP dims from the header before native decode.
const webpDims = readWebpDimensionsFromBuffer(buffer)
if (webpDims) {
return webpDims
}
// Why: nativeImage decodes PNG/JPEG/GIF/WebP/BMP. SVG isn't supported here
// (vector → no integer pixel grid), so pet bundles must use a raster sheet.
// Why: nativeImage can't decode SVG (vector → no pixel grid) — pet bundles must use a raster sheet.
const image = nativeImage.createFromBuffer(buffer)
if (image.isEmpty()) {
return null
@@ -160,12 +142,7 @@ async function readSheetDimensions(
return { width: size.width, height: size.height }
}
// Why: TOCTOU defense — between the `isSymlink` check and `copyFile`, a local
// attacker with write access to the bundle dir could swap the file with a
// symlink (copyFile follows symlinks). Open with O_NOFOLLOW so the open fails
// outright if the path is a symlink at the moment of open, then stream from
// the fd. On platforms without O_NOFOLLOW (Windows), the constant is undefined
// and we fall back to copyFile — symlinks aren't a meaningful threat there.
// Why: TOCTOU symlink-swap defense — O_NOFOLLOW makes open() fail on a symlink; Windows lacks it, so fall back to copyFile.
async function copyFileNoFollow(src: string, dest: string): Promise<void> {
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
if (noFollow === 0) {
@@ -196,11 +173,7 @@ export function registerPetHandlers(): void {
const options: Electron.OpenDialogOptions = {
title: 'Pick pet',
properties: ['openFile'],
// Why: single filter and no `apng` extension. macOS file dialogs map
// filter extensions to UTIs; `apng` has no registered UTI, so including
// it can drop sibling extensions (notably `webp`) from the allowed set.
// APNG files carry the `.png` extension and are detected from magic
// bytes by the browser.
// Why: omit `apng` — macOS maps dialog extensions to UTIs, and apng's missing UTI can drop siblings like webp (APNG uses .png anyway).
filters: [
{
name: 'Pet image',
@@ -237,9 +210,7 @@ export function registerPetHandlers(): void {
const dir = getPetsDir()
await mkdir(dir, { recursive: true })
const id = randomUUID()
// Why: preserve original extension in the on-disk name so pet:read can
// rebuild the right Blob MIME via resolvePetFile without a separate
// lookup. The extension is only ever written by main (never the renderer).
// Why: keep the original extension in the on-disk name so pet:read can rebuild the Blob MIME without a separate lookup.
const fileName = `${id}${classified.ext}`
const dest = join(dir, fileName)
try {
@@ -263,9 +234,7 @@ export function registerPetHandlers(): void {
ipcMain.handle('pet:importPetBundle', async (event): Promise<CustomPet | null> => {
const senderWindow =
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
// Why: the bundle is a folder. macOS users may also pick `pet.json` itself
// when Finder is set to show package contents — the post-pick logic walks
// up to the parent directory in that case.
// Why: the bundle is a folder, but Finder may let users pick `pet.json` inside it — post-pick logic walks up to the parent.
const options: Electron.OpenDialogOptions = {
title: 'Pick a .codex-pet bundle',
properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory']
@@ -302,8 +271,7 @@ export function registerPetHandlers(): void {
let manifest: ResolvedPetManifest<PetManifest>
try {
const raw = await readFile(manifestPath, 'utf8')
// Why: defend against TOCTOU between stat and read — the file could have
// grown after the stat check.
// Why: defend against TOCTOU — the file may have grown between the stat check and this read.
if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) {
throw new Error('pet.json exceeded the manifest size limit.')
}
@@ -312,11 +280,7 @@ export function registerPetHandlers(): void {
throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`)
}
// Why: spritesheetPath is bundle-relative. Codex pet.json files omit this
// path and use a fixed `spritesheet.webp`; applyCodexPetDefaults fills
// that shape before path validation. Reject absolute paths and any resolved
// path that escapes the bundle directory. Also reject symlinks so a
// malicious bundle can't reach outside via a sibling link.
// Why: spritesheetPath is bundle-relative and attacker-controlled — reject absolute/escaping paths (and symlinks) so a bundle can't reach outside.
const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep)
if (
isAbsolute(manifest.spritesheetPath) ||
@@ -325,17 +289,14 @@ export function registerPetHandlers(): void {
) {
throw new Error('spritesheetPath must be relative to the bundle.')
}
// Why: pet bundles may be exported on Windows and imported on macOS/Linux;
// normalize manifest separators before Node resolves the bundle-relative path.
// Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving.
const sheetSrc = resolve(bundleDir, normalizedSpritePath)
const bundleResolved = resolve(bundleDir)
if (sheetSrc === bundleResolved) {
throw new Error('spritesheetPath must point to a file, not the bundle root.')
}
const bundleRoot = bundleResolved + sep
// Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like
// `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on
// Windows so the prefix check isn't bypassed by case differences.
// Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check.
const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
throw new Error('spritesheetPath escapes the bundle.')
@@ -365,10 +326,7 @@ export function registerPetHandlers(): void {
let sprite: NonNullable<CustomPet['sprite']> | undefined
if (manifest.frame) {
// Why: only decode the sheet when we need to validate frame layout.
// nativeImage may fail on some WebP variants in headless contexts, and
// bundles without `frame` render as a static image where dimensions
// don't matter.
// Why: only decode when a frame layout needs validating — nativeImage can fail on some WebP variants in headless contexts.
const sheetBuf = await readFile(sheetSrc)
// Why: defend against TOCTOU — file may have grown between stat and read.
if (sheetBuf.byteLength > MAX_BYTES) {
@@ -419,19 +377,14 @@ export function registerPetHandlers(): void {
}
}
// Why: storage id is always a fresh UUID the manifest's `id` is purely
// a display hint. This guards against (a) collisions with bundled
// pet ids, (b) failing isSafeId, and (c) re-import clobbering an
// earlier copy of the same bundle.
// Why: always a fresh UUID (not the manifest's display-hint id) to avoid collisions, unsafe ids, and re-import clobbering.
const id = randomUUID()
const root = getPetsDir()
await mkdir(root, { recursive: true })
const destDir = join(root, id)
const sheetExt = sheetClass.ext
const sheetFileName = `spritesheet${sheetExt}`
// Why: stage the bundle into a sibling .tmp directory and atomically rename
// into place so destDir only appears once both files are written. Avoids
// half-imported bundles if a copy fails midway.
// Why: stage into a sibling .tmp then atomically rename, so a mid-copy failure can't leave a half-imported bundle.
const tmpDir = `${destDir}.tmp`
try {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
@@ -453,8 +406,7 @@ export function registerPetHandlers(): void {
mimeType: sheetClass.mimeType,
kind: 'bundle',
sprite,
// Why: carries manifest.fps for detected-frame bundles where `sprite`
// is undefined — renderer falls back to this when sprite is absent.
// Why: renderer falls back to spriteFps when sprite is undefined (detected-frame bundles).
...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {})
}
})
@@ -467,16 +419,14 @@ export function registerPetHandlers(): void {
fileName: string,
kind?: 'image' | 'bundle'
): Promise<ArrayBuffer | null> => {
// Why: validate IPC inputs before any path logic — renderer is not
// trusted to send strings of the right shape.
// Why: renderer inputs are untrusted; validate shape before any path logic.
let parsed: z.infer<typeof PetFileRequestSchema>
try {
parsed = PetFileRequestSchema.parse({ id, fileName, kind })
} catch {
throw new Error('Invalid pet:read arguments')
}
// Why: missing kind defaults to 'image' for backwards compatibility with
// pre-bundle persisted state.
// Why: default 'image' for backwards compat with pre-bundle persisted state.
const filePath = resolvePetFile(parsed.id, parsed.fileName, parsed.kind ?? 'image')
if (!filePath) {
return null
@@ -505,9 +455,7 @@ export function registerPetHandlers(): void {
return
}
if ((parsed.kind ?? 'image') === 'bundle') {
// Why: bundle imports own a whole directory. isSafeId already gates id;
// we still build the path under the pets root and verify the
// prefix before recursive removal as defense in depth.
// Why: defense in depth — verify path stays under pets root before recursive removal.
const root = normalize(getPetsDir())
const target = normalize(join(root, parsed.id))
if (!target.startsWith(root + sep)) {
+8 -48
View File
@@ -5,15 +5,7 @@ import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init'
import type { DaemonSessionInfo } from '../daemon/types'
// Why: the daemon's session.kill() sends SIGTERM first and escalates to
// SIGKILL after a 5s grace window (KILL_TIMEOUT_MS in session.ts). We have
// to poll past that ladder, or well-behaved-but-slow shells (zsh hosting a
// long-running agent) look like they "refused to exit" when they're actually
// still inside their SIGTERM handler waiting for SIGKILL. 65 polls at 100ms
// each (≈6.5s) covers the 5s ladder plus ~1.5s of slack for the final
// SIGKILL reap and the adapter's listSessions IPC roundtrip. The user waits
// during this window with a spinner; the alternative — reporting fake
// "refused" numbers — is worse.
// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused".
const MAX_POLL_ATTEMPTS = 65
const POLL_INTERVAL_MS = 100
@@ -32,9 +24,7 @@ function getDaemonAdapters(): DaemonPtyAdapter[] {
return [provider]
}
// Why: surface degraded mode (daemon alive but cannot spawn fresh PTYs) so the
// session-management UI can warn that new terminals lack daemon persistence
// until the daemon is restarted, instead of it being a silent console.warn.
// Why: surface degraded mode (daemon alive but cannot spawn fresh PTYs) so the UI can warn new terminals lack persistence.
function isDaemonDegraded(): boolean {
return getDaemonProvider() instanceof DegradedDaemonPtyProvider
}
@@ -66,12 +56,7 @@ export function registerDaemonManagementHandlers(): void {
}
)
// Why: killAll operates on *sessions* (user-facing concept), not daemons, so
// it fans across every adapter — current + legacy — to match the user's
// "kill everything I might be attached to" mental model. The daemon
// processes themselves survive; only sessions are torn down. See
// docs/daemon-staleness-ux.md §Phase 1 "Scope rationale" for why legacy
// daemons aren't killed here.
// Why: tears down sessions across all adapters (current + legacy); daemon processes survive. See docs/daemon-staleness-ux.md §Phase 1.
ipcMain.handle(
'pty:management:killAll',
async (): Promise<{
@@ -80,12 +65,7 @@ export function registerDaemonManagementHandlers(): void {
killedSessionIds: string[]
}> => {
const adapters = getDaemonAdapters()
// Why: snapshot the initial session set once, up front. All subsequent
// accounting is relative to these IDs. If the renderer respawns panes
// with *fresh* session IDs while we're polling (e.g. a remount fires
// pty:spawn mid-kill), those new sessions must not count as
// "remaining" — the user asked to kill what was alive at the moment
// they clicked the button, not to chase new spawns.
// Why: snapshot session IDs up front so mid-kill respawns aren't counted as "remaining".
const initial = await collectSessions(adapters)
const initialIds = new Set(initial.map((s) => s.sessionId))
const initialCount = initial.length
@@ -94,40 +74,20 @@ export function registerDaemonManagementHandlers(): void {
return { killedCount: 0, remainingCount: 0, killedSessionIds: [] }
}
// Why: fire one shutdown per initial session, in parallel, once — no
// per-session retry. The daemon's session.kill() is idempotent and
// schedules its own SIGTERM→SIGKILL ladder; firing the RPC repeatedly
// in a tight retry loop before the grace window expires just races our
// own polling. Promise.allSettled ensures a single adapter failure (or
// rejected RPC — e.g. session already exiting) does not short-circuit
// the remaining shutdowns.
// Why: no retry — session.kill() is idempotent and runs its own kill ladder; allSettled so one rejection doesn't abort the rest.
await Promise.allSettled(
initial.map(async (session) => {
// Why: protocolVersion is unique across adapters by construction —
// PROTOCOL_VERSION is always distinct from every entry in
// PREVIOUS_DAEMON_PROTOCOL_VERSIONS (see types.ts). If a future
// bump forgets to rotate the retired version into the previous
// list, this find() would silently route legacy sessions to the
// current adapter. Keep the two constants in lockstep.
// Why: assumes PROTOCOL_VERSION stays distinct from PREVIOUS_DAEMON_PROTOCOL_VERSIONS (types.ts), else legacy sessions misroute here.
const owner = adapters.find((a) => a.protocolVersion === session.protocolVersion)
if (!owner) {
return
}
// Why: immediate=true is the adapter's "kill it now" signal. The
// current adapter ignores the flag (the daemon's SIGTERM→SIGKILL
// ladder handles escalation) but preserve it for legacy adapters
// and so a future adapter could honor it. Rejections are swallowed
// per-session — remainingCount surfaces truly-stuck sessions in
// the toast.
// Why: immediate=true only matters to legacy/future adapters; swallow rejections since remainingCount reports stuck sessions.
await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {})
})
)
// Why: poll listSessions every POLL_INTERVAL_MS until none of the
// *initial* IDs are still alive, or we've exhausted MAX_POLL_ATTEMPTS.
// Counting only the initial-snapshot intersection (not the total
// session count) is what keeps the math honest when the renderer
// respawns panes with fresh IDs mid-kill.
// Why: count only the initial-snapshot intersection so renderer respawns mid-kill aren't counted as remaining.
let remainingOriginalCount = initialCount
let remainingOriginalIds = initialIds
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
+139 -388
View File
File diff suppressed because it is too large Load Diff
+263 -945
View File
File diff suppressed because it is too large Load Diff
+16 -51
View File
@@ -1,8 +1,4 @@
/* eslint-disable max-lines -- Why: this file groups all repos IPC handler
tests (addRemote, getBaseRefDefault envelope, searchBaseRefs SSH relay) so
fixture setup and mock plumbing can be shared. Splitting by line count would
duplicate the hoisted mocks and the `../git/repo` partial-real/partial-stub
setup. */
/* eslint-disable max-lines -- groups all repos IPC handler tests so shared fixture setup and hoisted mocks aren't duplicated */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
@@ -88,10 +84,7 @@ vi.mock('electron', () => ({
}))
vi.mock('../git/repo', async () => {
// Why: pull real implementations of pure helpers so SSH parity tests
// exercise the actual probe list and query sanitizer, not frozen copies.
// Drift in DEFAULT_BASE_REF_PROBES or normalizeRefSearchQuery now surfaces
// as test failure, not silent test-passes against stale behavior.
// Why: use real pure helpers so SSH parity tests catch drift in DEFAULT_BASE_REF_PROBES / normalizeRefSearchQuery.
const actual = await vi.importActual<typeof RepoModule>('../git/repo')
return {
...actual,
@@ -106,9 +99,7 @@ vi.mock('../git/repo', async () => {
})
vi.mock('../git/runner', async () => ({
// Why: keep the real env builders (nonInteractiveGitEnv,
// gitOptionalLocksDisabledEnv) so the clone regression test (#7652) asserts
// the actual guard's markers, not a mock echoing itself.
// Why: keep the real env builders so the clone regression test (#7652) asserts real markers, not a mock echoing itself.
...(await vi.importActual<typeof GitRunner>('../git/runner')),
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: vi.fn(),
@@ -2127,9 +2118,7 @@ describe('repos:add + repos:clone', () => {
destination
})
// Without this env, a clone that needs GitHub auth makes Git Credential
// Manager pop its "Connect to GitHub" OAuth window on Windows and loop it
// when the network cannot complete the flow.
// Without this env, a clone needing auth makes Git Credential Manager pop and loop its OAuth window on Windows.
expect(gitSpawnMock).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://example.com/orca.git', join(destination, 'orca')],
expect.objectContaining({
@@ -2437,9 +2426,7 @@ describe('repos:getBaseRefDefault envelope', () => {
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.getRepo.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
// Reset exec to default: later SSH tests replace this with custom mocks, and
// without this reset any future test added to this block would inherit the
// last test's exec mock — latent fragility we guard against here.
// Reset exec so a newly added test doesn't inherit the previous test's exec mock.
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
registerRepoHandlers(mockWindow as never, mockStore as never)
})
@@ -2481,10 +2468,7 @@ describe('repos:getBaseRefDefault envelope', () => {
expect(result.remoteCount).toBe(1)
})
// Why: the SSH handler resolves default-ref and remote-count in parallel
// (Promise.all) so the order of calls into provider.exec is not stable.
// Dispatch on argv instead of `mockResolvedValueOnce` chains so tests remain
// independent of which Promise in the Promise.all resolves first.
// Why: the handler resolves default-ref and remote-count in parallel, so dispatch on argv (not call order) to stay stable.
type ExecResponse = { stdout: string; stderr: string }
type ExecRule = {
matches: (argv: string[]) => boolean
@@ -2515,8 +2499,7 @@ describe('repos:getBaseRefDefault envelope', () => {
matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
// The origin/HEAD target is verified before it is trusted, so the
// symbolic-ref result must also resolve via rev-parse.
// origin/HEAD is verified before trusted, so it must also resolve via rev-parse.
{
matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.resolve({ stdout: '', stderr: '' })
@@ -2551,8 +2534,7 @@ describe('repos:getBaseRefDefault envelope', () => {
matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
// The origin/HEAD target is verified before it is trusted, so the
// symbolic-ref result must also resolve via rev-parse.
// origin/HEAD is verified before trusted, so it must also resolve via rev-parse.
{
matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.resolve({ stdout: '', stderr: '' })
@@ -2576,8 +2558,7 @@ describe('repos:getBaseRefDefault envelope', () => {
remoteCount: number
}
// Why: default detection must be independent of remote-count lookup.
// A failing count falls back to 0, but the default still resolves.
// Why: default detection is independent of remote-count; a failing count falls back to 0 while the default still resolves.
expect(result.defaultBaseRef).toBe('origin/main')
expect(result.remoteCount).toBe(0)
})
@@ -2616,9 +2597,7 @@ describe('repos:getBaseRefDefault envelope', () => {
remoteCount: number
}
// Why: when symbolic-ref fails, the probe chain should find
// refs/remotes/origin/master and return 'origin/master', matching
// the local path's getDefaultBaseRefAsync behavior.
// Why: when symbolic-ref fails, the probe chain resolves origin/master, matching the local path.
expect(result.defaultBaseRef).toBe('origin/master')
expect(result.remoteCount).toBe(1)
})
@@ -2711,8 +2690,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
query: '***'
})
// Why: normalizeRefSearchQuery still strips glob metacharacters before
// building argv; the resulting empty query now intentionally lists refs.
// Why: glob metacharacters are stripped, so the empty query intentionally lists refs.
const [argv] = mockGitProvider.exec.mock.calls.find(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)!
@@ -2791,10 +2769,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
})
it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => {
// Why: this is the core issue-624 behavior — the SSH path must glob all
// remotes, not just origin. The `**` globs additionally span ref segments
// so slash-named branches (`user/feature`) are found by a single-word
// query; a single `*` would not cross `/`.
// Why: SSH globs all remotes and `**` crosses `/` so slash-named branches match a single-word query (issue #624).
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
mockStore.getRepo.mockReturnValue({
@@ -2822,11 +2797,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
})
it('sends segmented argv for display-format queries like `upstream/main`', async () => {
// Why: guards against the SSH path drifting from the local path for
// multi-segment queries. The picker shows results as `<remote>/<branch>`
// and users retype that format; if the SSH argv reverts to a single
// `*<q>*` glob containing the literal `/`, SSH users silently see no
// matches for valid refs — the same shape of bug as issue #624.
// Why: a single `*<q>*` glob with a literal `/` makes SSH multi-segment queries silently match nothing (issue #624 shape).
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
mockStore.getRepo.mockReturnValue({
@@ -2849,18 +2820,14 @@ describe('repos:searchBaseRefs SSH relay', () => {
expect(segmentedArgv).toContain('refs/heads/*upstream*/*main*')
expect(branchRootArgv).toContain('refs/remotes/*/upstream/main*')
expect(branchRootArgv).toContain('refs/heads/upstream/main*')
// Regression guard: the literal slash must never appear inside a
// single segmented glob (would be `refs/remotes/*upstream/main*`),
// which fnmatch cannot match because `*` doesn't cross `/`.
// Regression guard: a literal slash must never appear inside a single segmented glob (`*` doesn't cross `/`).
expect(segmentedArgv).not.toContain('refs/remotes/*upstream/main*/*')
expect(segmentedArgv).not.toContain('refs/remotes/*/*upstream/main*')
expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
})
it('parses NUL-delimited stdout and filters <remote>/HEAD pseudo-refs', async () => {
// Why: exercises the shared parseAndFilterSearchRefs pipeline end-to-end
// on the SSH path — confirms the HEAD filter works for any remote (not
// just origin) when results come from the relay.
// Why: confirms the HEAD filter works for any remote, not just origin, on the SSH path.
const stdout = [
'refs/remotes/origin/main\0origin/main',
'refs/remotes/upstream/main\0upstream/main',
@@ -2901,9 +2868,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
query: 'main'
})
// Why: transport failure must fall back to an empty result set — mirrors
// the local path's catch, so SSH users see "no matches" instead of a
// crashed picker when the relay drops.
// Why: transport failure falls back to an empty result set so the picker doesn't crash.
expect(result).toEqual([])
})
+61 -204
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: repo IPC is intentionally centralized so SSH
routing, clone lifecycle, and store persistence stay behind a single audited
boundary. Splitting by line count would scatter tightly coupled repo behavior. */
/* eslint-disable max-lines -- Why: repo IPC is centralized so SSH routing, clone lifecycle, and store persistence stay behind one audited boundary. */
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'node:crypto'
@@ -103,31 +101,15 @@ import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-messag
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import { runWithGitReadCacheInvalidation } from '../git/status'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
// the path, URL, or display name. `repos:create` collapses into
// `folder_picker` because the user's entry was the folder picker, even
// though main also `git init`s. `drag_drop` is reserved for a future call
// site; no current renderer surface produces it.
//
// Why `isGitRepo`: low-cardinality, non-identifying git-vs-folder signal.
// Callers pass it because they already have the git-detection result in scope
// (avoids re-running git I/O here). Pass `undefined` when a call site genuinely
// can't determine git-ness (e.g. some SSH/remote edges) — never default-guess
// `false`. This replaced the now-removed `onboarding_completed.is_git_repo`,
// which became meaningless once repo selection left onboarding (1.4.46).
// Why: `method` is the IPC entry point the user took, not what they added (never path/URL/name); repos:create → 'folder_picker'.
// Why: `isGitRepo` is a non-identifying git-vs-folder signal from the caller's detection; pass undefined when unknown, never default false.
// Why: it replaced onboarding_completed.is_git_repo, which lost meaning once repo selection left onboarding (1.4.46).
function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?: boolean): void {
// Why: re-adding an existing repo (matched by path inside the handler)
// is not a new activation event. Suppressing the duplicate keeps the
// funnel honest and avoids inflating `repo_added` for users who
// re-pick the same folder.
// Why: re-adding an existing repo isn't a new activation; suppress so re-picking a folder doesn't inflate repo_added.
if (alreadyExisted) {
return
}
// Why: cohort must read AFTER `store.addRepo()` lands so the just-added
// repo is counted — every call site below already emits post-addRepo, so
// `getCohortAtEmit()` here returns the user's Nth `repo_added` as `N`.
// See docs/onboarding-funnel-cohort-addendum.md §Read-vs-write ordering.
// Why: read cohort AFTER store.addRepo() so the just-added repo is counted (docs/onboarding-funnel-cohort-addendum.md §Read-vs-write ordering).
const props = {
method,
...(isGitRepo === undefined ? {} : { is_git_repo: isGitRepo }),
@@ -157,9 +139,7 @@ function alignRepoWithRequestedProject(
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: setup-on-host is an explicit user action for this project. When the
// folder lacks upstream metadata but the selected project has provider
// identity, stamp that identity so compatibility projection can merge it.
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
@@ -227,8 +207,7 @@ async function addLocalRepoFromPath(
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
// Why: new Add Project imports should become explicit ready host
// setups; `legacy-repo` is reserved for older records/projection.
// Why: new Add Project imports are explicit ready host setups; 'legacy-repo' is reserved for older records/projection.
projectHostSetupMethod: 'imported-existing-folder' as const
}
: {})
@@ -404,12 +383,9 @@ async function cloneRemoteRepo(
activeRemoteClone = metadata
remoteCloneInFlightByPath.add(remoteCloneKey)
try {
// Why: local clone creates the typed parent before spawning git. SSH clone
// must match that behavior or a fresh remote parent surfaces as spawn ENOENT.
// Why: match local clone by creating the parent first, or a fresh remote parent surfaces as spawn ENOENT.
await fsProvider.createDir(trimmedDestination)
// Why: the SSH relay exposes argv-based git execution, not a shell. Use
// the repo folder name as the target so git creates it inside the chosen
// parent, and keep the same flag separator safety as local clone.
// Why: the SSH relay runs git argv, not a shell; use the repo folder name so git creates it under the chosen parent.
await gitProvider.clone(
['clone', '--progress', '--', args.url.trim(), repoName],
trimmedDestination,
@@ -615,8 +591,7 @@ async function resolveRemoteHomePath(connectionId: string, path: string): Promis
const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string }
return result.resolvedPath
} catch {
// Why: older relays may not support this yet; callers will surface the
// original path validation error instead of failing during resolution.
// Why: older relays may not support this; return the original path so callers surface their own validation error.
return path
}
}
@@ -638,9 +613,7 @@ type ActiveRemoteCloneMetadata = {
controller: AbortController
}
// Why: module-scoped so the abort handle survives window re-creation on macOS.
// registerRepoHandlers is called again when a new BrowserWindow is created,
// and a function-scoped variable would lose the reference to an in-flight clone.
// Why: module-scoped so the abort handle survives macOS window re-creation, when registerRepoHandlers re-runs.
let activeClone: ActiveCloneMetadata | null = null
let activeRemoteClone: ActiveRemoteCloneMetadata | null = null
let nextCloneGeneration = 1
@@ -923,8 +896,7 @@ async function cleanupOwnedCloneTarget(metadata: ActiveCloneMetadata): Promise<v
if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) {
return
}
// Why: an immediate retry can attach a newer process to the same target
// before the aborted process closes; the old close handler must not delete it.
// Why: a fast retry may attach a newer process before the aborted one closes; the old close handler must not delete it.
if (
activeClone &&
activeClone.process !== metadata.process &&
@@ -1120,8 +1092,7 @@ async function runNestedRepoScanForIpc(
}
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
// Remove previously registered handlers so we can re-register on macOS app re-activation (new window).
ipcMain.removeHandler('repos:list')
ipcMain.removeHandler('repos:add')
ipcMain.removeHandler('repos:remove')
@@ -1172,9 +1143,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
// Why: username resolution spawns git/gh and must stay off this handler's
// synchronous path (issue #7225); the background pass notifies the
// renderer to re-list once values land.
// Why: username resolution spawns git/gh, so keep it off this sync handler (issue #7225); it re-lists when values land.
enrichRepoGitUsernames(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
@@ -1614,8 +1583,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id)
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
// Why: nested-repo import only reaches here after the isGitRepo /
// isGitRepoAsync guard above confirmed a git repo, so always `true`.
// Why: reaches here only after the isGitRepo guard above confirmed a git repo, so always true.
emitRepoAdded('folder_picker', false, true)
} catch (error) {
results.push({
@@ -1708,9 +1676,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
// Creates a new repo or folder from scratch (orca#763). An empty initial
// commit is required for git repos so HEAD has a branch ref — Orca's
// worktree features all need one.
// Create a repo/folder from scratch (orca#763); git repos need an empty initial commit so HEAD has a branch ref for worktrees.
ipcMain.handle(
'repos:create',
async (
@@ -1719,62 +1685,44 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
): Promise<{ repo: Repo } | { error: string }> => {
const name = args.name?.trim() ?? ''
const parentPath = args.parentPath?.trim() ?? ''
// Why: IPC input is untrusted — coerce to the narrow union so a bogus
// string (e.g. "x") can't skip git init yet persist as kind: "x" in the
// store. Mirrors the coercion in repos:add above.
// Why: IPC input is untrusted — coerce to the narrow union so a bogus kind can't skip git init yet persist in the store.
const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git'
if (!name) {
return { error: 'Name cannot be empty' }
}
// Block slashes and ./.. so the name can't escape the chosen parent.
// The UI already disables submit in these cases; this guards direct IPC use.
// Block slashes and ./.. so the name can't escape the chosen parent (guards direct IPC use).
if (/[\\/]/.test(name) || name === '.' || name === '..') {
return { error: 'Name cannot contain slashes or be "." / ".."' }
}
if (!parentPath) {
return { error: 'Parent directory is required' }
}
// Why: blocks CWD-relative paths from slipping through the IPC boundary;
// the UI uses pickDirectory which returns absolute paths, this guards
// direct IPC use (and keeps targetPath stable across process cwd changes).
// Why: block CWD-relative paths at the IPC boundary — keeps targetPath stable across process cwd changes.
if (!isAbsolute(parentPath)) {
return { error: 'Parent directory must be an absolute path' }
}
const targetPath = join(parentPath, name)
// Dedup by path (same as repos:add) so a double-click on Create doesn't
// produce two sidebar entries pointing at the same folder. This is the
// first of three dedup checks; see the pre-addRepo check below for why
// the race matters even after this one passes.
// Dedup by path so a double-click on Create doesn't make two entries for one folder (first of three dedup checks).
const existing = store.getRepos().find((r) => r.path === targetPath)
if (existing) {
emitRepoAdded('folder_picker', true, repoKind === 'git')
return { repo: existing }
}
// Empty pre-existing directories are allowed (e.g. one the user made in
// Finder first). Non-empty ones are rejected so we don't overwrite files.
// Empty pre-existing dirs are allowed (e.g. made in Finder first); non-empty ones are rejected so we don't overwrite files.
let createdDir = false
let targetExists = false
try {
// Why: the name-first default points at ~/orca/projects, which may not
// exist yet on a fresh install; create only the parent before probing target.
// Why: the default parent (~/orca/projects) may not exist on a fresh install; create only the parent before probing the target.
await mkdir(parentPath, { recursive: true })
await access(targetPath)
targetExists = true
} catch (err) {
// Why: only ENOENT means "the path is free to use". Other codes
// (EACCES, ENOTDIR, EPERM, ELOOP, ...) mean something is in the way
// that mkdir can't fix — surface a precise error instead of falling
// through to mkdir and returning a misleading "Failed to create
// directory" message.
//
// Why the message fallback: fs.promises.access always attaches a
// NodeJS.ErrnoException code in production, but plain Error objects
// thrown in tests / non-Node contexts won't — treat a message that
// reads like ENOENT as one so we don't over-reject.
// Why: only ENOENT means the path is free; other codes are something mkdir can't fix, so surface a precise error.
// Why: tests/non-Node errors lack a code, so treat an ENOENT-looking message as ENOENT to avoid over-rejecting.
const code =
err && typeof err === 'object' && 'code' in err
? (err as NodeJS.ErrnoException).code
@@ -1797,9 +1745,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
}
} catch (err) {
// Why: access succeeded but readdir failed — the path exists but we
// can't inspect it (e.g. it's a file, not a directory; or perms).
// mkdir would definitely fail here too, so return a distinct error.
// Why: access ok but readdir failed — path exists but isn't an inspectable dir (file or perms); return a distinct error.
const message = err instanceof Error ? err.message : String(err)
return { error: `Failed to read directory: ${message}` }
}
@@ -1808,11 +1754,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
await mkdir(targetPath, { recursive: false })
createdDir = true
} catch (err) {
// Why: EEXIST here means another concurrent repos:create for the
// same path won the mkdir race. If they already added the repo to
// the store, return that entry instead of a confusing error. This
// is the second dedup check; see the pre-addRepo check below for
// the full race explanation.
// Why: EEXIST means a concurrent repos:create won the mkdir race; return its store entry instead of a confusing error.
const code =
err && typeof err === 'object' && 'code' in err
? (err as NodeJS.ErrnoException).code
@@ -1830,9 +1772,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
if (repoKind === 'git') {
// Why: track which git step is running so the catch can attribute the
// failure correctly. The identity-hint regex is only meaningful during
// commit — git init itself never produces "Please tell me who you are".
// Why: track which git step ran so catch can attribute failure; the identity-hint regex only applies during commit.
let step: 'init' | 'commit' = 'init'
try {
await gitExecFileAsync(['init'], { cwd: targetPath })
@@ -1841,12 +1781,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
cwd: targetPath
})
} catch (err) {
// Only remove the directory if we made it. A pre-existing folder the
// user picked must survive so they can retry after fixing git config.
// Why: if we didn't make the directory but `git init` created `.git/`
// inside it, strip just `.git/` so the user's folder looks the way
// they left it. Retrying works either way, but leaving a half-init'd
// repo behind is confusing if they choose to skip the retry.
// Only rm the dir if we made it (pre-existing folders must survive retry); otherwise strip just the .git/ that git init created.
if (createdDir) {
await rm(targetPath, { recursive: true, force: true }).catch(() => {})
} else if (step === 'commit') {
@@ -1870,16 +1805,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
}
// Why: ipcMain.handle doesn't serialize concurrent calls; re-running the
// dedup lookup here closes the window between the first check and
// addRepo. A second repos:create for the same path that raced past the
// initial dedup now returns the entry the first call persisted.
// Why: ipcMain.handle doesn't serialize calls, so re-check dedup here to close the race between the first check and addRepo.
const raceWinner = store.getRepos().find((r) => r.path === targetPath)
if (raceWinner) {
// Why: do NOT rm even if this invocation created the directory — the
// other invocation is using it. Leaking a freshly-made empty folder on
// a rare race is strictly safer than deleting a directory the winning
// call (and the user) now owns.
// Why: don't rm even if we made the dir — the race winner owns it; leaking an empty folder beats deleting a dir in use.
emitRepoAdded('folder_picker', true, repoKind === 'git')
return { repo: raceWinner }
}
@@ -1906,8 +1835,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
await prepareLocalWorktreeRootForRepo(store, repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
// Why: `repos:create` git-inits when kind is 'git', so `repoKind` is the
// true git-vs-folder signal for the just-created project.
// Why: repos:create git-inits when kind is 'git', so repoKind is the true git-vs-folder signal.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
}
@@ -1916,9 +1844,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:reorder',
(_event, args: { orderedIds: string[] }): { status: 'applied' | 'rejected' } => {
// Why: validate at the IPC boundary — IPC input is untrusted and a
// permutation mismatch means the renderer's drag was stale relative to
// a concurrent add/remove. Reject so the renderer can resync.
// Why: a permutation mismatch means the renderer's drag was stale vs a concurrent add/remove; reject so it can resync.
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
const applied = store.reorderRepos(ids)
if (applied) {
@@ -1955,9 +1881,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
notifyReposChanged(mainWindow)
})
// Why: forget a project on a single execution host without disturbing the
// same repo id on other hosts (local or a re-added SSH target). Used by the
// SSH-workspace forget flow when a host is removed/disconnected.
// Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow).
ipcMain.handle(
'repos:removeForHost',
async (_event, args: { repoId: string; hostId: string }) => {
@@ -2006,12 +1930,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
}
) => {
// Why: validate the persisted preference string at the IPC boundary
// — the TypeScript signature is erased at runtime, and a preload
// version skew or renderer bug could otherwise persist a garbage
// string that silently collapses to 'auto' in `resolveIssueSource`
// (see gh-utils.ts#resolveIssueSource). Strip rather than throw so
// other valid fields in the same call still persist.
// Why: TS is erased at runtime, so a garbage preference would silently collapse to 'auto' in resolveIssueSource; strip it, keeping other fields.
const updates = { ...args.updates }
if (
'issueSourcePreference' in updates &&
@@ -2031,12 +1950,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.forkSyncMode
}
// Why: `symlinkPaths` is consumed by worktree path materialization, which
// calls `.trim()` on each entry. A renderer bug or preload-version skew
// that persists a non-`string[]` value (e.g. `[42, null]`, a bare
// string) would throw inside the worktree-create path with no UI
// signal. Strip invalid shapes at the boundary the same way
// `issueSourcePreference` is validated above.
// Why: worktree materialization calls .trim() per entry, so strip non-string[] at the boundary to avoid a silent throw later.
if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) {
const v = updates.symlinkPaths as unknown
if (!Array.isArray(v) || !v.every((e) => typeof e === 'string')) {
@@ -2115,8 +2029,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
delete updates.importedExternalWorktreePaths
}
}
// Why: null is the transport sentinel for clearing Source Control AI.
// Other invalid fields are deleted; this one must flow as undefined.
// Why: null is the transport sentinel for clearing Source Control AI, so flow it through as undefined instead of deleting.
if ('sourceControlAi' in updates && updates.sourceControlAi === null) {
updates.sourceControlAi = undefined
} else if ('sourceControlAi' in updates && updates.sourceControlAi !== undefined) {
@@ -2142,10 +2055,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
// ── Sparse presets ─────────────────────────────────────────────
// Why: presets are repo-scoped reusable directory lists used by the
// new-workspace composer. Persisted via Store and broadcast back to the
// renderer so any open composer reflects new/edited/deleted presets
// immediately.
// Why: repo-scoped reusable directory lists for the new-workspace composer; broadcast on change so open composers refresh.
ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => {
return store.getSparsePresets(args.repoId)
@@ -2210,13 +2120,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return result.filePaths
})
// Why: pickDirectory is a generic "choose a folder" picker, separate from
// pickFolder which is specifically the "add project" flow. Clone needs a
// destination directory that may not be a git repo yet.
// Why: generic folder picker, separate from pickFolder's add-project flow; a clone destination may not be a git repo yet.
ipcMain.handle('repos:pickDirectory', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
// Why: macOS can materialize typed partial paths when directory creation
// is enabled; clone/create actions already create the final path on submit.
// Why: macOS materializes typed partial paths with directory creation on; clone/create make the final path on submit.
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) {
@@ -2242,10 +2149,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:clone',
async (_event, args: { url: string; destination: string }): Promise<Repo> => {
// Why: the user picks a parent directory (e.g. ~/projects) and we derive
// the repo folder name from the URL (e.g. "orca" from .../orca.git).
// This matches the default git clone behavior where the last path segment
// of the URL becomes the directory name.
// Why: derive the repo folder name from the URL's last segment, matching default git clone behavior.
const clonePath = deriveValidatedClonePath(args)
const clonePathKey = getClonePathComparisonKey(clonePath)
return runWithClonePathLock(clonePathKey, async () => {
@@ -2258,35 +2162,21 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
emitRepoAdded('clone_url', true, true)
return existingAfterPendingClone
}
// Why: gitSpawn uses args.destination as cwd, so it must exist before
// spawn — fresh installs may have a defaulted parent dir that does not
// exist yet (e.g. ~/orca). recursive: true is a no-op when present.
// Why: gitSpawn cwd is args.destination, so it must exist before spawn (fresh installs may lack the defaulted parent).
await mkdir(args.destination, { recursive: true })
const claimedTarget = await claimCloneTarget(clonePath)
// Why: use spawn instead of execFile so there is no maxBuffer limit.
// git clone writes progress to stderr which can exceed Node's default
// 1 MB buffer on large or submodule-heavy repos. We only keep the tail
// of stderr for error reporting and discard stdout entirely.
// Why: use --progress to force git to emit progress even when stderr
// is not a TTY. Without it, git suppresses progress output when piped.
// Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default.
// Why: --progress forces git to emit progress even when stderr isn't a TTY.
const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null }
await new Promise<void>((resolve, reject) => {
// Why: clone destination may be a WSL path (e.g. user picks a WSL
// directory). Use the parent destination as the cwd so the runner
// detects WSL and routes through wsl.exe.
// Why: use the '--' separator to isolate the URL argument and prevent
// malicious URLs from being interpreted as git flags (command injection).
// Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe.
// Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection).
let proc: ReturnType<typeof gitSpawn>
try {
proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
cwd: args.destination,
// Why: without the non-interactive guard, a clone that needs
// GitHub auth makes Git Credential Manager pop its "Connect to
// GitHub" OAuth window on Windows; in a network-restricted env the
// browser/device flow can never complete and git's credential
// retry re-pops it (issue #7652). Fail fast with a clear error and
// let Orca's non-intrusive GitHub state stand instead.
// Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652).
env: nonInteractiveGitEnv(),
stdio: ['ignore', 'ignore', 'pipe']
})
@@ -2318,8 +2208,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const text = chunk.toString()
stderrTail = (stderrTail + text).slice(-4096)
// Why: git progress lines use \r to overwrite in-place; parse
// fragments the same way for local and SSH clone flows.
// Why: git progress lines use \r to overwrite in-place; parse fragments the same as SSH clone.
emitCloneProgressFromText(mainWindow, text)
})
@@ -2332,18 +2221,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return
}
settled = true
// Why: only clear the ref if it still points to this process.
// A quick abort-and-retry can reassign activeClone to a new
// spawn before this handler fires, and nulling it would make the
// new clone unabortable.
// Why: only null activeClone if it still points to this proc; abort-and-retry may have reassigned it, stranding the new clone.
if (activeClone?.process === proc) {
activeClone = null
}
const cloneSucceeded = !err && code === 0 && !signal
if (!cloneSucceeded) {
// Why: only the process that created this target may remove it,
// and only after git reports the clone did not complete.
// Why: only the process that created this target may remove it, and only after git reports failure.
await cleanupOwnedCloneTarget(metadata)
}
if (metadata.abortRequested && !cloneSucceeded) {
@@ -2376,10 +2261,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
})
try {
// Why: check after clone (not before) because the path didn't exist
// before cloning. But if the user somehow had a folder repo at this path
// that git clone succeeded into (empty dir), reuse that entry and upgrade
// its kind to 'git' instead of creating a duplicate.
// Why: check after clone (path didn't exist before); reuse+upgrade a folder repo clone landed into instead of duplicating.
const existing = store
.getRepos()
.find((r) => getClonePathComparisonKey(r.path) === clonePathKey)
@@ -2449,8 +2331,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (!repo || isFolderRepo(repo)) {
return ''
}
// Why: remote repos have their git config on the remote host. Keep this
// to explicit username config; user.email/name are author identity.
// Why: remote repos keep their git config on the remote host, so resolve the username there.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
@@ -2469,30 +2350,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
): Promise<BaseRefDefaultResult> => {
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
// Why: folder-mode repos have no git state to resolve a base ref from.
// Return null + 0 so the renderer can decline to use a fabricated default
// and suppress the multi-remote hint.
// Why: folder repos have no git state for a base ref; return null + 0 so the renderer skips a fabricated default.
return { defaultBaseRef: null, remoteCount: 0 }
}
// Why: remote repos need the relay to resolve symbolic-ref on the
// remote host where the git data lives.
// Why: remote repos need the relay to resolve symbolic-ref where the git data lives.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return { defaultBaseRef: null, remoteCount: 0 }
}
// Why: run default-ref resolution and remote-count concurrently to
// match the local path's latency characteristics (see Promise.all
// below). The two lookups are independent — neither depends on the
// other's result — so serializing them only adds SSH round-trip
// latency on slow relays.
//
// Why: delegate to the shared resolveDefaultBaseRefViaExec so SSH and
// local repos return identical defaults for equivalent states. We
// log in the exec callback for the symbolic-ref call to preserve the
// SSH-specific transport-failure diagnostic (connection drops,
// permission issues) that the shared helper otherwise swallows
// together with the expected "origin/HEAD unset" non-zero exit.
// Why: delegate to shared resolveDefaultBaseRefViaExec; log symbolic-ref failures here to keep the SSH transport diagnostic it otherwise swallows.
const resolveDefault = async (): Promise<string | null> => {
return resolveDefaultBaseRefViaExec(async (argv) => {
try {
@@ -2514,8 +2381,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const remotesResult = await provider.exec(['remote'], repo.path)
return parseRemoteCount(remotesResult.stdout)
} catch (err) {
// Why: fall back to 0 (the "unknown / do not render the multi-remote
// hint" sentinel). Log so diagnostic signal isn't lost.
// Why: 0 = unknown sentinel that suppresses the multi-remote hint.
console.warn('[repos:getBaseRefDefault] SSH git remote count failed', {
path: repo.path,
err
@@ -2530,9 +2396,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
])
return { defaultBaseRef, remoteCount }
}
// Why: compute default and remote count independently. A failure
// counting remotes must not break default detection. Run in parallel
// since the two lookups don't depend on each other.
// Why: run in parallel; a remote-count failure must not break default detection.
const [defaultBaseRef, remoteCount] = await Promise.all([
getBaseRefDefault(repo.path),
getRemoteCount(repo.path)
@@ -2580,13 +2444,10 @@ async function searchBaseRefDetailsForRepo(
if (!provider) {
return []
}
// Why: mirror the local path's sanitization (normalizeRefSearchQuery
// in ../git/repo.ts) — strip glob metacharacters to prevent glob
// injection via the SSH branch while preserving empty-query branch lists.
// Why: strip glob metacharacters to prevent glob injection (mirrors local normalizeRefSearchQuery).
const normalizedQuery = normalizeRefSearchQuery(args.query)
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH and local paths cannot drift.
// Why: argv lives in buildSearchBaseRefsArgv so SSH and local paths cannot drift.
const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
const remotes = remotesResult.stdout
.split('\n')
@@ -2620,10 +2481,7 @@ async function searchBaseRefDetailsForRepo(
isForEachRefExcludeUnsupportedError
)
}
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.
// See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
// HEAD-filter rationale.
// Why: delegate the parse/filter/dedup/limit pipeline to the shared helper so SSH and local paths cannot diverge.
const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0)
if (searchTokens.length > 1) {
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
@@ -2652,8 +2510,7 @@ function getRepoForExecutionHost(
if (!hostId) {
return store.getRepo(repoId) ?? null
}
// Why: repo ids can collide across local and SSH hosts; base-ref reads must
// use the same host selected by the Settings pane as the subsequent write.
// Why: repo ids can collide across local and SSH hosts; read must use the same host the Settings pane selected for the write.
return (
store
.getRepos()
+16 -73
View File
@@ -10,23 +10,10 @@ export type RemoteDirEntry = {
const SSH_BROWSE_TIMEOUT_MS = 15_000
// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX
// "command not found" convention, identical across sh/bash/zsh and locales). It's
// the locale-independent signal that the Windows fallback never actually ran, so
// the original POSIX failure — not the doomed retry — is the real error.
//
// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value
// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit
// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2
// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec`
// wrapper is detected by "the remote command ran and exited non-zero"
// (RemoteBrowseError), not by a magic exit code or localized stderr text.
// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one.
const POSIX_COMMAND_NOT_FOUND_EXIT = 127
// Carries the raw exit code so the fallback can (a) recognize that the remote
// command actually ran and failed — the locale-independent trigger for the
// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart
// from a genuine PowerShell error, without parsing localized shell prose.
// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error.
class RemoteBrowseError extends Error {
constructor(
message: string,
@@ -37,10 +24,7 @@ class RemoteBrowseError extends Error {
}
}
// Why: the relay's fs.readDir enforces workspace root ACLs, which aren't
// registered until a repo is added. This handler uses a raw SSH exec channel
// to list directories, allowing the user to browse the remote filesystem
// during the "add remote project" flow before any roots exist.
// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec.
export function registerSshBrowseHandler(
getConnectionManager: () => SshConnectionManager | null
): void {
@@ -64,22 +48,14 @@ export function registerSshBrowseHandler(
try {
return await browseWithPosixShell(conn, args.dirPath)
} catch (posixError) {
// Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX
// `exec` wrapper, and the only locale-independent signal for that is "the
// remote command executed and exited non-zero" (RemoteBrowseError). Its
// stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches
// us (sshd forwards process exit 1). Transport errors/timeouts aren't
// RemoteBrowseErrors, so a dropped connection is never retried as Windows.
// Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows.
if (!(posixError instanceof RemoteBrowseError)) {
throw posixError
}
try {
return await browseWithWindowsPowerShell(conn, args.dirPath)
} catch (fallbackError) {
// Why: if the login shell couldn't find powershell.exe (exit 127) the
// host isn't Windows — surface the original POSIX failure rather than a
// misleading "powershell.exe: not found". Otherwise PowerShell genuinely
// ran and its error (e.g. "Cannot find path") is the real cause.
// Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause.
throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError
}
}
@@ -93,13 +69,7 @@ function browseWithPosixShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
// Why: using one line per entry preserves filenames containing spaces.
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
// code rather than being indistinguishable from an empty directory.
// Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs.
return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
}
@@ -109,17 +79,12 @@ function browseWithWindowsPowerShell(
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
const script = [
"$ErrorActionPreference = 'Stop'",
// Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM
// code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so
// non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake.
// Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake.
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
`$dir = ${powerShellPathExpression(dirPath)}`,
'Set-Location -LiteralPath $dir',
'$resolved = (Get-Location).ProviderPath',
// Why: the renderer's parentPath/joinPath only split on `/`, so a native
// backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a
// forward-slash resolvedPath (matching the POSIX branch) while keeping the
// native $resolved for Get-ChildItem -LiteralPath.
// Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem.
"Write-Output ($resolved -replace '\\\\', '/')",
'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {',
" if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }",
@@ -176,9 +141,7 @@ async function runBrowseCommand(
}
}
const onTimeout = (): void => {
// Why: remote browsing runs before a relay workspace root exists, so
// it cannot rely on relay request deadlines. Bound this raw exec
// channel directly to keep Add Remote Project from hanging forever.
// Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever.
rejectOnce(new Error('Remote directory listing timed out'))
closeChannel()
}
@@ -197,8 +160,7 @@ async function runBrowseCommand(
const onStderrData = (data: Buffer): void => {
stderr += data.toString()
}
// `exit` fires before `close`; capture the code so we can distinguish
// a failed `ls` that still produced `pwd` output from an empty listing.
// `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing.
const onExit = (code: number | null): void => {
exitCode = code
}
@@ -206,13 +168,7 @@ async function runBrowseCommand(
rejectOnce(error)
}
const onClose = (): void => {
// A null exitCode means the server closed the channel without
// sending an exit-status message (or signalled termination). We
// can't assume success — falling back to "empty stdout = empty
// directory" is exactly the bug the exit-code branch was added to
// fix. Treat any non-zero OR null exit as a failure when stderr
// has content, and otherwise require stdout to contain at least
// the resolved `pwd` line before accepting the result.
// Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir.
if (exitCode !== 0) {
const msg =
stderr.trim() ||
@@ -227,9 +183,7 @@ async function runBrowseCommand(
return
}
// Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a
// trailing \r defeats the endsWith('/') dir check and leaves a stray CR
// in every name.
// Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names.
const lines = stdout.trim().split(/\r?\n/)
if (lines.length === 0) {
rejectOnce(new Error('Empty response from remote'))
@@ -266,8 +220,7 @@ async function runBrowseCommand(
channel.stderr.on('data', onStderrData)
channel.on('exit', onExit)
channel.on('close', onClose)
// Why: SSH exec streams emit `error` on transport loss; without a
// scoped listener, a disappearing remote can become process-fatal.
// Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal.
channel.on('error', onError)
channel.stderr.on('error', onError)
timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
@@ -277,17 +230,12 @@ async function runBrowseCommand(
})
}
// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the
// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX
// failure, not the doomed retry, is the error worth surfacing.
// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead.
function isPosixCommandNotFound(error: unknown): boolean {
return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT
}
// Why: prevent shell injection in the directory path. Single-quote wrapping
// with escaped internal single quotes is the safest approach for sh/bash.
// Tilde must be expanded by the shell, so paths starting with ~ use $HOME
// substitution instead of literal quoting (single quotes suppress expansion).
// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde expansion.
function shellEscape(s: string): string {
if (s === '~') {
return '"$HOME"'
@@ -312,12 +260,7 @@ function powerShellPathExpression(s: string): string {
return powerShellLiteral(normalizeWindowsDrivePath(s))
}
// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them
// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the
// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter
// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's
// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both
// back to a rooted drive path here so navigation lands where the user clicked.
// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path.
function normalizeWindowsDrivePath(s: string): string {
const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '')
return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped
+53 -173
View File
@@ -1,5 +1,4 @@
/* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward
broadcasting, and session lifecycle in one file to keep the data flow obvious. */
/* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward broadcasting, and session lifecycle to keep the data flow obvious. */
import { ipcMain, powerMonitor, type BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { SshConnectionStore } from '../ssh/ssh-connection-store'
@@ -71,9 +70,7 @@ const SSH_IPC_CHANNELS = [
'ssh:listDetectedPorts'
] as const
// Why: connection callbacks are process-lifetime; keeping this set outside
// registerSshHandlers prevents in-flight connects from splitting credential
// tracking when a BrowserWindow is recreated.
// Why: keep this outside registerSshHandlers so a BrowserWindow recreation mid-connect doesn't split credential tracking.
const credentialRequestedForTarget = new Set<string>()
function getCurrentMainWindow(): BrowserWindow | null {
@@ -113,15 +110,12 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
if (!sshStore) {
return
}
// Why: removing a target is destructive — dispose() (not detach()) so the
// relay shuts down and remote PTY leases are terminated rather than preserved
// for a reattach to a target that will no longer exist.
// Why: removal is destructive — dispose() (not detach()) so the relay shuts down and remote PTY leases are terminated, not preserved for a reattach.
await disposeActiveSshSession(targetId)
try {
await connectionManager?.disconnect(targetId)
} catch (err) {
// Why: a failed disconnect must not block metadata removal; otherwise the
// target lingers in the store and its leases are never cleaned up.
// Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases.
console.warn(
`[ssh] Failed to disconnect removed target ${targetId}: ${err instanceof Error ? err.message : String(err)}`
)
@@ -130,9 +124,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
sshStore.removeTarget(targetId)
}
// Why: one session per SSH target encapsulates the entire relay lifecycle
// (multiplexer, providers, abort controller, state machine). Eliminates the
// scattered Maps/Sets that previously tracked this state independently.
// One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine).
const activeSessions = new Map<string, SshRelaySession>()
export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
@@ -168,8 +160,7 @@ async function teardownActiveSshSession(
if (!session) {
return
}
// Why: await port teardown so local listeners are fully released before
// disconnect/remove completes; otherwise immediate reconnect can hit EADDRINUSE.
// Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE.
await portForwardManager?.removeAllForwards(targetId)
teardown(session)
activeSessions.delete(targetId)
@@ -181,31 +172,16 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number
return target?.relayGracePeriodSeconds
}
// Why: multiple renderer tabs for the same SSH target can fire ssh:connect
// concurrently. Without serialization, the second call interleaves with the
// first — both see no existing session, both create one, and the first one
// leaks. This map holds the in-flight connect promise so the second call
// awaits the first rather than racing.
// Why: concurrent ssh:connect from multiple tabs would each create a session (leaking the first); hold the in-flight promise so the second awaits it.
const connectInFlight = new Map<string, Promise<SshConnectionState>>()
// Why: reset tears down and force-stops the relay, then disconnects SSH.
// Publish that lifecycle so new connects and duplicate resets cannot race it.
// Why: publish reset's teardown/force-stop/disconnect lifecycle so new connects and duplicate resets can't race it.
const resetRelayInFlight = new Map<string, Promise<void>>()
// Why: ssh:testConnection calls connect() then disconnect(), which fires
// state-change events to the renderer. This causes worktree cards to briefly
// flash "connected" then "disconnected". Suppressing broadcasts during tests
// avoids that visual glitch.
// Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected.
const testingTargets = new Set<string>()
// Why: when a relay channel keeps dying (e.g. a stale --connect bridge keeps
// being replaced, a remote-side bug closes the channel right after handshake,
// or a mismatched relay binary refuses every handshake), the unguarded
// _onRelayLost handler reconnects as fast as the network allows, hammering
// both the local main process and the remote sshd in a tight loop. Track
// per-target reconnect attempts and apply exponential backoff so the loop
// terminates with a recoverable error instead of running forever. Successful
// post-ready uptime resets the attempt counter for the next genuine drop.
// Why: without backoff, a relay channel that keeps dying reconnects as fast as the network allows, hammering local + remote sshd; track attempts and back off to end the loop recoverably.
type RelayLostBackoffState = {
attempts: number
reconnectTimer: ReturnType<typeof setTimeout> | null
@@ -216,12 +192,7 @@ const relayStateOverrides = new Map<string, SshConnectionState>()
const RELAY_LOST_MAX_ATTEMPTS = 6
const RELAY_LOST_BASE_DELAY_MS = 500
const RELAY_LOST_MAX_DELAY_MS = 15_000
// Why: if a fresh reconnect's mux dies within this window, the new session
// never stabilized (a flap, not a real recovery). Without this clamp the
// attempt counter would be reset prematurely by a mux that "reached ready"
// only on paper. 5 seconds covers normal post-deploy provider registration
// and PTY reattach without being so generous that a real long-lived session
// looks like a flap.
// Why: a reconnect whose mux dies within this window was a flap, not a recovery — don't reset the attempt counter. 5s covers provider re-registration + PTY reattach.
const RELAY_LOST_STABILIZED_MS = 5_000
function clearRelayLostBackoff(targetId: string): void {
@@ -240,9 +211,7 @@ function broadcastSshState(
targetId: string,
state: SshConnectionState
): void {
// Why: runtime-owned (ephemeral-VM) targets are hidden from the renderer, which
// has no surface for them. Broadcasting their state would make the renderer fire
// a listTargets() lookup per event (incl. each relay-lost reconnect) for nothing.
// Why: runtime-owned (ephemeral-VM) targets are hidden from the renderer, so broadcasting their state only triggers wasted listTargets() lookups.
if (isRuntimeOwnedSshTargetId(targetId)) {
currentRuntime?.invalidateSshWorktreeScanCache?.(targetId)
return
@@ -252,8 +221,7 @@ function broadcastSshState(
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', { targetId, state: enrichedState })
}
// Why: paired remote clients have no ssh:state-changed IPC; without this
// their terminals keep a stale reconnect overlay after the host connects.
// Why: paired remote clients have no ssh:state-changed IPC; without this their terminals keep a stale reconnect overlay.
currentRuntime?.notifySshStateChanged?.(targetId, enrichedState)
}
@@ -279,8 +247,7 @@ function clearRelayStateOverride(targetId: string): void {
}
function connectionSupportsFolderDownload(targetId: string): boolean {
// Why: ready legacy/test connections without an explicit transport are ssh2-shaped;
// only a confirmed system-SSH transport must remove the SFTP-only capability.
// Why: connections without an explicit transport are ssh2-shaped; only a confirmed system-SSH transport lacks the SFTP-only capability.
return connectionManager?.getConnection(targetId)?.usesSystemSshTransport?.() !== true
}
@@ -340,10 +307,7 @@ function enrichDetected(
)
}
// Why: after user-initiated add/remove/update the runtime manager is the
// single source of truth — write exactly its entries and nothing else.
// A separate helper (persistPortForwardsWithUnrestored) preserves entries
// that failed to restore so they retry on next reconnect.
// Why: after user add/remove/update the runtime manager is the source of truth — persist exactly its entries (unrestored ones handled by a separate helper).
function persistPortForwards(targetId: string): void {
const active = portForwardManager!.listForwards(targetId)
const saved: SavedPortForward[] = active.map((f) => ({
@@ -355,9 +319,7 @@ function persistPortForwards(targetId: string): void {
sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined })
}
// Why: called after restorePortForwards so that forwards which failed to
// restore (e.g. port temporarily busy) are kept in the persisted list and
// retried on next reconnect, rather than being silently dropped.
// Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped.
function persistPortForwardsWithUnrestored(targetId: string): void {
const active = portForwardManager!.listForwards(targetId)
const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`))
@@ -392,14 +354,9 @@ async function restorePortForwards(
return
}
// Why: don't prune failed restores from persisted state. A failure may
// be transient (e.g. port temporarily busy at startup) and the forward
// should be retried on the next reconnect rather than silently deleted.
// Why: keep failed restores in persisted state — a failure may be transient (port temporarily busy), so retry on next reconnect.
for (const saved of target.portForwards) {
// Why: if the session disconnects/reconnects while this loop is running,
// a new connection object is created. Checking identity avoids adding
// forwards against a stale connection, which would leak local listeners
// that the next reconnect's removeAllForwards() doesn't know about.
// Why: a reconnect mid-loop swaps the connection object; bail on identity change so we don't add forwards to a stale conn (leaking listeners).
if (connectionManager!.getConnection(targetId) !== conn) {
return
}
@@ -425,9 +382,7 @@ async function restorePortForwards(
function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null): void {
advertisedUrlWatcherUnsubscribe?.()
// Why: SSH port scans only emit when raw host/port/PID data changes. A
// terminal can print the advertised URL after the raw port row is already
// visible, so the watcher must also trigger a renderer refresh.
// Why: SSH port scans only emit on raw host/port/PID changes, but a terminal can print the advertised URL later, so the watcher must also refresh the renderer.
advertisedUrlWatcherUnsubscribe = advertisedUrlWatcher.onDidChange(({ worktreeId }) => {
if (!persistedStore) {
return
@@ -439,8 +394,7 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
}
const scanner = session.getPortScanner()
if (scanner) {
// Why: watcher changes can arrive before the next SSH scan refreshes
// listener PIDs; cached scanner rows must not pin a fresh URL stale.
// Why: watcher changes can arrive before the next SSH scan refreshes listener PIDs, so don't validate PIDs against cached scanner rows.
broadcastDetectedPorts(getMainWindow, targetId, scanner.getDetectedPorts(targetId), {
validatePid: false
})
@@ -450,8 +404,7 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
})
}
// Why: macOS can resume the process before the network stack is back up, so
// a failed first probe gets one retry before the link is declared dead (#7773).
// Why: macOS can resume before the network is back, so a failed first probe gets one retry before the link is declared dead (#7773).
const RESUME_PROBE_TIMEOUT_MS = 5_000
const RESUME_PROBE_ATTEMPTS = 2
@@ -483,15 +436,11 @@ function registerPowerMonitorReconnect(): void {
continue
}
void (async () => {
// Why: unconditional reconnect on every wake tore down live sessions
// and flashed the reconnect overlay (#7773). Only reconnect targets
// whose relay link actually died during sleep.
// Why: unconditional reconnect on wake tore down live sessions and flashed the overlay (#7773); only reconnect if the relay link actually died during sleep.
if (await isRelayLinkAliveAfterResume(session)) {
return
}
// Why: the probe can take ~10s. If the user disconnected or the
// session/connection was replaced meanwhile, reconnecting would
// resurrect a connection that was intentionally torn down.
// Why: the probe can take ~10s; bail if the session/connection was replaced or torn down meanwhile, else we'd resurrect it.
if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) {
return
}
@@ -526,10 +475,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
return
}
// Why: when SSH reconnects after a network blip, we must re-deploy the
// relay and rebuild the full provider stack. The session's state machine
// ensures this only triggers when appropriate — 'deploying' state from
// an explicit ssh:connect is not 'ready', so this branch won't fire.
// Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'.
const session = activeSessions.get(targetId)
const sessionState = session?.getState()
const shouldReconnectRelay =
@@ -539,8 +485,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
(sessionState === 'ready' || sessionState === 'reconnecting')
if (shouldReconnectRelay) {
// Why: SSH is connected before the relay providers are rebuilt. Keep
// renderer actions gated until SshRelaySession reaches ready again.
// Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again.
publishRelayOverride(
getCurrentMainWindow,
targetId,
@@ -556,10 +501,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
if (!session) {
return
}
// Why: allow reconnect from both 'ready' (normal network blip) and
// 'reconnecting' (previous reconnect attempt failed, e.g. relay deploy
// error on a working SSH connection). Without the 'reconnecting' check,
// a failed relay deploy would permanently brick the session.
// Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session.
if (shouldReconnectRelay) {
const target = sshStore?.getTarget(targetId)
const conn = connectionManager?.getConnection(targetId)
@@ -599,9 +541,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
}
const t = sshStore?.getTarget(tid)
// Why: bounded exponential backoff. Without this, a remote-side bug
// that closes every fresh --connect channel turns into an infinite
// tight loop spawning relay deploys until the user force-quits.
// Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop.
const state = relayLostBackoff.get(tid) ?? {
attempts: 0,
reconnectTimer: null,
@@ -619,9 +559,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
`[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.`
)
relayLostBackoff.delete(tid)
// Why: surface the failure so the renderer can prompt the user.
// A still-live SSH connection with a dead relay is otherwise an
// invisible failure — typing in remote terminals just stops working.
// Why: surface the failure — a live SSH connection with a dead relay is otherwise invisible (typing in remote terminals just stops working).
publishRelayOverride(
getCurrentMainWindow,
tid,
@@ -655,17 +593,14 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
)
})
// Why: fires after both establish() and reconnect() reach 'ready'.
// Re-creates persisted port forwards so they survive app restarts
// and network blips without manual re-configuration.
// Why: fires after both establish() and reconnect() reach 'ready'; re-create persisted port forwards so they survive restarts and blips.
session.setOnReady((tid) => {
const state = relayLostBackoff.get(tid)
if (state) {
if (state.stabilizedTimer) {
clearTimeout(state.stabilizedTimer)
}
// Why: stabilization is post-ready uptime. Slow deployment time before
// `ready` does not prove the new relay survived user-visible work.
// Why: stabilization counts post-ready uptime; slow deploy time before `ready` doesn't prove the new relay survived real work.
state.stabilizedTimer = setTimeout(() => {
const current = relayLostBackoff.get(tid)
if (current === state && !current.reconnectTimer) {
@@ -709,9 +644,7 @@ export function registerSshHandlers(
getMainWindow: () => BrowserWindow | null,
runtime?: OrcaRuntimeService
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
// Why: on macOS, app re-activation creates a new BrowserWindow and re-calls
// this function. ipcMain.handle() throws if a handler is already registered,
// so we must remove any prior handlers before re-registering.
// Why: macOS re-activation re-calls this with a new BrowserWindow; ipcMain.handle() throws on a duplicate channel, so remove prior handlers first.
for (const ch of SSH_IPC_CHANNELS) {
ipcMain.removeHandler(ch)
}
@@ -750,9 +683,7 @@ export function registerSshHandlers(
// ── Target CRUD ────────────────────────────────────────────────────
// Why: SSH target add/import can re-adopt workspaces orphaned on a removed
// target id (see ssh-target-readoption). When that re-points repos, the
// renderer must refresh its repo list to surface the reattached workspaces.
// Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them.
function takeRepoReadoptions(): SshRepoReadoption[] {
if (!sshStore || sshStore.lastRepoReadoptions.length === 0) {
return []
@@ -776,9 +707,7 @@ export function registerSshHandlers(
ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit<SshTarget, 'id'> }) => {
const target = sshStore!.addTarget(args.target)
// Why: re-adding a removed host can re-adopt orphaned workspaces (re-point
// repos/worktrees off the dead id). Refresh the renderer's repo list so the
// reattached workspaces move from grey ghosts back onto the live host.
// Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host.
const repoReadoptions = takeRepoReadoptions()
return { target, repoReadoptions }
})
@@ -808,9 +737,7 @@ export function registerSshHandlers(
await reset
}
// Why: serialize concurrent ssh:connect calls for the same target.
// Multiple tabs can fire connect simultaneously; without this, they
// interleave and the first session leaks.
// Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session.
const existing = connectInFlight.get(targetId)
if (existing) {
return existing
@@ -850,22 +777,16 @@ export function registerSshHandlers(
!relayStateOverrides.has(targetId) &&
!relayLostBackoff.has(targetId)
) {
// Why: BrowserWindow reactivation reruns renderer startup, which calls
// ssh:connect for already-live targets. Treat that as a refresh instead
// of tearing down the relay and stranding active port forwards.
// Why: BrowserWindow reactivation re-fires ssh:connect for already-live targets; treat as a refresh instead of tearing down the relay and its forwards.
broadcastSshState(getCurrentMainWindow, targetId, existingState)
return existingState
}
clearRelayStateOverride(targetId)
let conn
// Why: dispose any existing session to avoid leaking the old multiplexer,
// providers, and timers. This handles double-connect (user clicks connect
// while already connected) and reconnect-after-error.
// Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error).
if (existingSession) {
// Why: await port teardown before disposing so the OS fully releases
// local ports. Without this, restorePortForwards in the new session
// can hit EADDRINUSE on the same ports the old session was using.
// Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports.
await portForwardManager!.removeAllForwards(targetId)
existingSession.detach()
activeSessions.delete(targetId)
@@ -873,8 +794,7 @@ export function registerSshHandlers(
clearRelayStateOverride(targetId)
}
// Why: create the session early so onStateChange sees it in 'deploying'
// state and knows not to trigger reconnect logic.
// Why: create the session early so onStateChange sees it in 'deploying' and skips reconnect logic.
const session = new SshRelaySession(
targetId,
getCurrentMainWindow,
@@ -889,16 +809,10 @@ export function registerSshHandlers(
try {
conn = await connectionManager!.connect(target)
} catch (err) {
// Why: SshConnection.connect() sets its internal state, but the
// onStateChange callback may not have propagated to the renderer.
// Explicitly broadcast so the UI leaves 'connecting'.
// Why: connect()'s internal state may not have reached the renderer; broadcast explicitly so the UI leaves 'connecting'.
const errObj = err instanceof Error ? err : new Error(String(err))
const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error'
// Why: if a credential prompt was shown before the failure, the target
// would stay in credentialRequestedForTarget. A later successful connect
// that doesn't prompt would then incorrectly persist lastRequiredPassphrase
// = true, causing startup to defer this target even though it no longer
// needs a passphrase.
// Why: clear this failed connect's credential flag so a later non-prompting connect can't persist lastRequiredPassphrase=true.
credentialRequestedForTarget.delete(targetId)
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
@@ -913,7 +827,6 @@ export function registerSshHandlers(
}
try {
// Deploy relay and establish multiplexer
callbacks.onStateChange(targetId, {
targetId,
status: 'deploying-relay',
@@ -923,10 +836,7 @@ export function registerSshHandlers(
await session.establish(conn, relayGracePeriodForTarget(target))
// Why: we manually pushed `deploying-relay` above, so the renderer's
// state is stuck there. Send `connected` directly to the renderer
// instead of going through callbacks.onStateChange, which would
// trigger the reconnection logic.
// Why: we manually pushed `deploying-relay`, so send `connected` straight to the renderer — routing through onStateChange would trigger reconnect logic.
clearRelayStateOverride(targetId)
broadcastSshState(getCurrentMainWindow, targetId, {
targetId,
@@ -936,17 +846,13 @@ export function registerSshHandlers(
supportsFolderDownload: conn.usesSystemSshTransport?.() !== true
})
} catch (err) {
// Relay deployment failed — disconnect SSH
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
await connectionManager!.disconnect(targetId)
throw err
}
// Why: persist whether this connection required a credential prompt so
// startup reconnect can partition targets into eager vs deferred without
// re-probing keys. Updated on every successful connect so the flag stays
// current as users add/remove passphrases from their keys.
// Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys.
const requiredPassphrase = credentialRequestedForTarget.has(targetId)
credentialRequestedForTarget.delete(targetId)
sshStore!.updateTarget(targetId, { lastRequiredPassphrase: requiredPassphrase })
@@ -1008,8 +914,7 @@ export function registerSshHandlers(
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
}
if (shutdownFailures.length > 0) {
// Why: a failed relay shutdown can leave the remote process alive in the
// grace window. Keep the lease/session intact so the user can retry.
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`)
}
if (session) {
@@ -1026,8 +931,7 @@ export function registerSshHandlers(
const inFlightConnect = connectInFlight.get(targetId)
if (inFlightConnect) {
try {
// Why: reset tears down activeSessions; doing that while doConnect is
// still deploying can dispose the session doConnect is about to use.
// Why: await the in-flight connect first; tearing down activeSessions mid-deploy would dispose the session doConnect is about to use.
await inFlightConnect
} catch {
// The reset can still recover a stale remote relay after a failed connect.
@@ -1037,8 +941,7 @@ export function registerSshHandlers(
const session = activeSessions.get(targetId)
if (session) {
await portForwardManager!.removeAllForwards(targetId)
// Why: reset has its own stale-relay lease semantics below. dispose()
// records clean PTY termination, which hides reset-affected leases.
// Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide.
session.detach()
activeSessions.delete(targetId)
clearRelayLostBackoff(targetId)
@@ -1056,17 +959,13 @@ export function registerSshHandlers(
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
}
}
// Why: reset force-kills the remote relay daemon, so every local PTY
// handle owned by that relay is stale even if the reset command failed
// after the remote process accepted SIGTERM.
// Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM.
for (const ptyId of ptyIds) {
const appPtyId = toAppSshPtyId(targetId, ptyId)
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
}
// Why: reset's connect() can trip onCredentialRequest, which adds to
// credentialRequestedForTarget. Without this delete, a later doConnect
// that doesn't prompt would still persist lastRequiredPassphrase=true.
// Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true.
credentialRequestedForTarget.delete(targetId)
await connectionManager!.disconnect(targetId)
}
@@ -1099,12 +998,7 @@ export function registerSshHandlers(
return getPublicSshState(args.targetId)
})
// Why: callers that want to auto-connect (Cmd+J jump, terminal reattach) need
// to know whether doing so will pop a passphrase/password dialog. Auto-firing
// the connect is fine when no prompt is needed, but surprising otherwise —
// the user expects to enter the credential before the app starts connecting.
// Returns true if the target's last successful connect required a credential
// AND the live SshConnection (if any) does not already have one cached.
// Why: auto-connect callers need to know whether connecting will prompt; true when the last connect required a credential and no live conn has it cached.
ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
if (!target?.lastRequiredPassphrase) {
@@ -1120,12 +1014,7 @@ export function registerSshHandlers(
throw new Error(`SSH target "${args.targetId}" not found`)
}
// Why: testConnection calls connect() then disconnect(). If the target
// already has an active relay session, connect() would reuse the connection
// but disconnect() would tear down the entire relay stack — killing all
// active PTYs and file watchers for a "test" that was supposed to be safe.
// Also guard 'reconnecting' — disconnect() would kill the SSH connection
// that the in-flight reconnect is using for relay deployment.
// Why: with a live/reconnecting session, testConnection's disconnect() would tear down the relay stack (PTYs, watchers), so skip.
const existingSession = activeSessions.get(args.targetId)
const sessionState = existingSession?.getState()
if (
@@ -1136,9 +1025,7 @@ export function registerSshHandlers(
return { success: true, state: connectionManager!.getState(args.targetId) }
}
// Why: if a real ssh:connect is in flight for this target, testConnection's
// disconnect() call would tear down the connection that doConnect is using
// for relay deployment. Wait for the in-flight connect to finish instead.
// Why: testConnection's disconnect() would tear down an in-flight connect's relay deployment; await it instead.
const inFlight = connectInFlight.get(args.targetId)
if (inFlight) {
try {
@@ -1165,10 +1052,7 @@ export function registerSshHandlers(
}
} finally {
testingTargets.delete(args.targetId)
// Why: the shared onCredentialRequest callback adds to this set for
// any connect() call, including testConnection. Without clearing it,
// a later real connect that doesn't prompt would persist
// lastRequiredPassphrase=true, causing startup to defer this target.
// Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup.
credentialRequestedForTarget.delete(args.targetId)
}
})
@@ -1235,9 +1119,7 @@ export function registerSshHandlers(
broadcastPortForwards(getCurrentMainWindow, entry.connectionId)
return entry
} catch (err) {
// Why: if the edit failed (and rollback may also have failed),
// sync the renderer with the actual runtime state so it doesn't
// show a forward that no longer exists.
// Why: edit/rollback may have failed, so resync renderer to actual runtime state.
persistPortForwards(args.targetId)
broadcastPortForwards(getCurrentMainWindow, args.targetId)
throw err
@@ -1257,9 +1139,7 @@ export function registerSshHandlers(
ipcMain.handle('ssh:listPortForwards', (_event, args?: { targetId?: string }) => {
const all = portForwardManager!.listForwards(args?.targetId)
if (!persistedStore || !args?.targetId) {
// Why: the cross-target list is rare and we cannot map every entry to
// worktrees in a single call; serve the raw list. Per-target callers
// get full enrichment.
// Why: cross-target entries can't be mapped to worktrees in one call, so serve the raw list.
return all
}
return enrichSshForwardEntries(all, getWorktreeIdsForConnection(persistedStore, args.targetId))
+16 -67
View File
@@ -1,10 +1,4 @@
// IPC boundary behavior for the telemetry surface. Strict type narrows must
// drop obviously-malformed calls before they reach the validator (the
// renderer is in the threat model). Pins the consent-mutation rate limit:
// ≤5 consent-related IPC calls per session. Pins the main-side `via`
// derivation: both `OptInVia` branches are reachable from renderer input,
// and the one path that must NOT emit (`acknowledgeBanner`) has its own
// channel and handler rather than being a flag on `setOptIn`.
// Telemetry IPC boundary tests: the renderer is in the threat model, so handlers drop malformed calls, cap consent mutations, and derive `via` main-side.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/types'
@@ -58,9 +52,7 @@ function captureHandlers(): void {
}
}
// Build a fake Store with a settable `telemetry` block. Tests reassign
// `settings.telemetry` between handler invocations to seed the two
// derivation states.
// Fake Store whose `telemetry` block tests reassign between invocations to seed derivation states.
type FakeStoreState = { settings: GlobalSettings }
function makeFakeStore(telemetry: GlobalSettings['telemetry']): {
store: Store
@@ -125,10 +117,7 @@ describe('telemetry IPC handlers', () => {
expect(trackMock).toHaveBeenCalledWith('app_opened', { nth_repo_added: 2 })
})
// The IPC handler's selectivity is load-bearing: schemas are `.strict()`,
// so injecting `nth_repo_added` on a non-cohort event would silently
// drop the entire event at the validator. Events outside `COHORT_EXTENDED`
// must reach `track()` unmodified.
// Schemas are `.strict()`, so injecting `nth_repo_added` on a non-cohort event would drop the whole event at the validator.
it('does NOT inject cohort on events outside COHORT_EXTENDED', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
const handler = handlers.get('telemetry:track')!
@@ -141,9 +130,7 @@ describe('telemetry IPC handlers', () => {
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
})
// The renderer-only Setup-step events fire from React `onClick` and
// depend on the IPC handler injecting cohort — call sites stay
// synchronous and pass only their own props.
// Renderer-only Setup-step events depend on the handler injecting cohort so call sites stay synchronous.
it('injects cohort for add_repo_setup_step_action (renderer-only event)', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 1 })
@@ -213,8 +200,7 @@ describe('telemetry IPC handlers', () => {
})
})
// Fail-soft: a degraded classifier returns `{ nth_repo_added: undefined }`.
// The schemas declare the field optional, so the event still validates.
// Fail-soft: `nth_repo_added` is optional, so an undefined cohort still validates.
it('forwards undefined cohort when the classifier returns undefined', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined })
@@ -223,11 +209,7 @@ describe('telemetry IPC handlers', () => {
expect(trackMock).toHaveBeenCalledWith('app_opened', { nth_repo_added: undefined })
})
// Threat-model parity with the cohort override test: a compromised
// renderer must NOT be able to forge `nth_repo_added` either. The same
// spread-order invariant applies — `{ ...baseProps, ...getCohortAtEmit() }`
// — and the same future-refactor regression risk exists. Pinning both
// fields keeps the threat model symmetric.
// Security: same spread-order invariant as cohort — main-derived `nth_repo_added` overrides any renderer-forged value.
it('main-derived nth_repo_added overrides renderer-supplied value', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
@@ -265,11 +247,7 @@ describe('telemetry IPC handlers', () => {
expect(trackMock).toHaveBeenCalledWith('onboarding_started', { cohort: undefined })
})
// Threat-model invariant: a compromised renderer must NOT be able to forge
// `cohort` by including it in the props payload. The IPC handler spreads
// the main-derived cohort AFTER the caller-supplied props, so the main
// value wins. This test pins that invariant — flipping the spread order
// would silently let a compromised renderer fake any cohort value.
// Security: main spreads cohort after caller props so main wins; flipping the order would let a renderer forge `cohort`.
it('main-derived cohort overrides renderer-supplied cohort', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: 'fresh_install' })
@@ -281,20 +259,12 @@ describe('telemetry IPC handlers', () => {
})
})
// Threat-model invariant under degraded classifier: a compromised
// renderer must NOT be able to forge `cohort` even when the classifier
// fails soft to `{ cohort: undefined }`. The IPC handler spreads the
// classifier output AFTER the caller-supplied props, so an explicit
// `undefined` from the classifier still overwrites a forged value. A
// future refactor that switches the spread to a conditional assign
// (`if (c.cohort !== undefined) baseProps.cohort = c.cohort`) would
// silently regress this — pinning it here.
// Security: a fail-soft undefined cohort must still overwrite a forged value; a conditional-assign refactor would regress this.
it('main-derived undefined cohort overrides renderer-supplied cohort (degraded classifier)', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: true, optedIn: true })
getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: undefined })
const handler = handlers.get('telemetry:track')!
// Compromised renderer attempts to forge cohort='upgrade_backfill';
// main strips it via the explicit-undefined spread.
// Forged cohort='upgrade_backfill' is stripped by the explicit-undefined spread.
handler({}, 'onboarding_started', { cohort: 'upgrade_backfill' })
expect(trackMock).toHaveBeenCalledWith('onboarding_started', {
cohort: undefined
@@ -354,9 +324,7 @@ describe('telemetry IPC handlers', () => {
// ── telemetry:setOptIn — `via` derivation ────────────────────────────
it("derives via='first_launch_banner' for an existing user with optedIn=null clicking Turn off", () => {
// Existing-user notice is the only path where an existing user (cohort
// marker true) with optedIn=null flips to false. That is the contract
// the notice's "Turn off" button routes through.
// Only path where an existing user (optedIn=null) flips to false: the notice's "Turn off" button.
registerWith({
installId: 'x',
existedBeforeTelemetryRelease: true,
@@ -368,12 +336,7 @@ describe('telemetry IPC handlers', () => {
})
it("derives via='settings' (not 'first_launch_banner') for a defensive opt-in call from the pre-notice state", () => {
// Defensive: the notice's opt-in path is the ✕ (silent acknowledge),
// which does NOT route through setOptIn. A compromised renderer
// could try to call telemetrySetOptIn(true) in the pre-notice state
// and synthesize a spurious telemetry_opted_in { via:
// 'first_launch_banner' }. The derivation must refuse that tag for
// the true-incoming case and fall through to 'settings'.
// Security: refuse via='first_launch_banner' on a forged setOptIn(true) pre-notice; fall through to 'settings'.
registerWith({
installId: 'x',
existedBeforeTelemetryRelease: true,
@@ -385,10 +348,7 @@ describe('telemetry IPC handlers', () => {
})
it("derives via='settings' for a new user toggling off from Settings (no first-launch surface exists)", () => {
// New users (existedBeforeTelemetryRelease=false) are initialized with
// optedIn=true at migration and see no first-launch surface. Any
// opt-out from this cohort routes through Settings → Privacy and
// must tag as `via: 'settings'`.
// New users (existed=false) see no first-launch surface, so any opt-out routes through Settings.
registerWith({
installId: 'x',
existedBeforeTelemetryRelease: false,
@@ -400,8 +360,7 @@ describe('telemetry IPC handlers', () => {
})
it("derives via='settings' for an opt-in toggle flip after a prior opt-out", () => {
// User flipped off in Settings, flipping back on in Settings. Neither
// cohort marker nor notice state triggers a first-launch tag.
// Re-opt-in from Settings: neither cohort marker nor notice state triggers a first-launch tag.
registerWith({
installId: 'x',
existedBeforeTelemetryRelease: true,
@@ -424,8 +383,7 @@ describe('telemetry IPC handlers', () => {
})
it("derives via='settings' when the telemetry block is missing (defensive)", () => {
// Should never happen post-migration, but if it does the handler must
// fall through to 'settings' rather than throwing or mis-tagging.
// Should never happen post-migration; handler must fall through to 'settings', not throw.
registerWith(undefined)
const handler = handlers.get('telemetry:setOptIn')!
handler({}, true)
@@ -435,10 +393,7 @@ describe('telemetry IPC handlers', () => {
// ── telemetry:acknowledgeBanner — silent-persist path ────────────────
it('routes banner ✕ through persistBannerAcknowledgeWithoutEmitting without invoking setOptIn', () => {
// This is the whole point of the separate channel: the silent-persist
// path MUST NOT reach setOptIn, which would derive a `via` and fire
// `telemetry_opted_in`. The client primitive may unlock `app_opened`,
// but the acknowledge channel itself must not emit an opt-in event.
// Why the separate channel: reaching setOptIn would derive a `via` and fire `telemetry_opted_in`; acknowledge must persist silently.
registerWith({
installId: 'x',
existedBeforeTelemetryRelease: true,
@@ -464,13 +419,7 @@ describe('telemetry IPC handlers', () => {
})
// ── telemetry:acknowledgeBanner — state-precondition guard ───────────
// These tests pin the guard, which rejects any cohort/optedIn
// combination other than (existed=true, optedIn=null). The guard is
// the defense against a compromised renderer silently flipping
// optedIn=true for a user who already resolved consent — a future
// refactor that weakens it must fail here. The guard also runs BEFORE
// consumeConsentMutationToken, so a rejected call must not burn a
// token either.
// Security: guard passes only (existed=true, optedIn=null) and runs before token consumption, blocking a compromised renderer from flipping resolved consent.
it('acknowledgeBanner rejects an existing user who already opted in', () => {
registerWith({
+30 -152
View File
@@ -1,39 +1,13 @@
// IPC surface for the telemetry transport. Four handlers, all renderer-
// facing: one pipe (`telemetry:track`), one consent-mutation
// (`telemetry:setOptIn`), one silent-persist for the banner ✕ path
// (`telemetry:acknowledgeBanner`), and one read-only getter for effective
// consent state (`telemetry:getConsentState`, used by the Privacy pane to
// render env-var blocked-state helper text). Every track call from the
// renderer lands here and funnels into the same `track()` the main-
// originated events go through — the validator is the single enforcement
// point, not this file.
// IPC surface for telemetry: `track`, `setOptIn`, `acknowledgeBanner`, and read-only
// `getConsentState`. Renderer track calls funnel into the same `track()` as main-originated
// events; the validator there is the single enforcement point, not this file.
//
// Threat model: the renderer renders attacker-controllable content (agent
// output, MCP responses, file contents, markdown, diff views). An
// XSS-equivalent rendering bug in any of those surfaces gives an attacker
// the ability to invoke `window.api.telemetry*` at will. The handlers
// below are designed to fail closed under that model:
//
// - Strict main-side type narrows. TypeScript types do not survive IPC
// serialization; the renderer can pass anything across the wire, so we
// narrow at the boundary. Non-string `name` or non-object `props` on
// `track` → drop silently. Non-boolean `optedIn` on `setOptIn` → drop.
// - Consent-mutation rate limit. A real user flips the Privacy pane
// toggle a handful of times at most; beyond 5 per session it is either
// a UI bug or a compromised renderer. Drop silently past the cap.
// Applies to `acknowledgeBanner` as well — that path also mutates
// persisted consent, so it lives under the same per-session ceiling.
//
// `via` derivation: the renderer does NOT pass `via` across the wire. That
// design was rejected specifically because a compromised renderer could
// misreport `via`, muddying the one signal we use to distinguish
// first-launch interactions from settings flips. Main derives `via` from
// fields main already owns (`existedBeforeTelemetryRelease`, current
// `optedIn`) before any state mutation. The two product paths map cleanly
// to the two schema values (`first_launch_banner` for the existing-user
// notice's "Turn off" button; `settings` for everything else — new users
// have no first-launch surface, so their opt-outs always come through
// Settings).
// Threat model: the renderer displays attacker-controllable content (agent output, MCP
// responses, markdown, diffs), so an XSS-equivalent bug lets an attacker call
// `window.api.telemetry*`. These handlers fail closed: strict main-side type narrows (TS
// types don't survive IPC), a ≤5/session consent-mutation cap (covers `acknowledgeBanner`
// too), and `via` derived from main-owned state — never passed over the wire, so a
// compromised renderer can't misreport it.
import { ipcMain } from 'electron'
import { consumeConsentMutationToken } from '../telemetry/burst-cap'
@@ -46,11 +20,7 @@ import { isCohortExtendedEvent, isOnboardingEvent } from '../../shared/telemetry
import type { EventName, EventProps } from '../../shared/telemetry-events'
import type { OptInVia } from '../../shared/telemetry-events'
// Module-level store reference, populated by `registerTelemetryHandlers`.
// The handlers need a synchronous read of `settings.telemetry` at call time
// to derive `via` before any mutation runs — threading the store through
// every handler closure is the least-surprising shape for that, and it
// mirrors how other core-handlers accept the store explicitly.
// Module-level store ref: handlers need a synchronous `settings.telemetry` read to derive `via` before any mutation.
let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
@@ -60,44 +30,18 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
])
/**
* Derive the `via` discriminator for a `telemetry:setOptIn` call from
* main-owned state. Called BEFORE any state mutation so the cohort + opt-in
* snapshot reflects the pre-click world.
*
* Two cases (mirrors the product surfaces defined in telemetry-plan.md):
* - existing-user notice "Turn off" `'first_launch_banner'`
* (existedBeforeTelemetryRelease=true, optedIn=null, incoming false)
* - any other flip `'settings'`
* (Privacy pane, post-first-launch mutations, etc.)
*
* New users never reach a `'first_launch_banner'` tag their cohort marker
* is false and they see no first-launch surface at all (see telemetry-plan.md
* §First-launch experience). Any consent flip they make routes through
* Settings Privacy and tags as `'settings'`.
*
* Note: the notice's (silent acknowledge) path does NOT come through this
* function it routes through `telemetry:acknowledgeBanner` and
* `persistBannerAcknowledgeWithoutEmitting`, which intentionally does not
* emit. Derivation here would tag it as `'first_launch_banner'` and emit
* `telemetry_opted_in`, which the -as-silent-acknowledge semantics
* forbid.
* Derive the `via` discriminator for `telemetry:setOptIn` from main-owned state.
* Called BEFORE any mutation so the snapshot reflects the pre-click world.
* Existing-user notice "Turn off" `first_launch_banner`; any other flip `settings`
* (new users have no first-launch surface, so their opt-outs always tag `settings`; see telemetry-plan.md).
* (The silent-acknowledge path routes through `telemetry:acknowledgeBanner`, not here.)
*/
function deriveOptInVia(store: Store, incomingOptedIn: boolean): OptInVia {
const telemetry = store.getSettings().telemetry
const existedBefore = telemetry?.existedBeforeTelemetryRelease === true
const currentOptedIn = telemetry?.optedIn
// Existing-user cohort, notice still pending. The only surface that calls
// `setOptIn` in this state is the FirstLaunchBanner's "Turn off" click;
// the ✕ path does not route here (it goes through
// `telemetry:acknowledgeBanner`). We additionally narrow on
// `incomingOptedIn === false` as a defensive guard: a compromised
// renderer could otherwise call `telemetrySetOptIn(true)` in this
// pre-notice state and synthesize a spurious
// `telemetry_opted_in { via: 'first_launch_banner' }`, which the
// ✕-as-silent-acknowledge contract forbids. Falling through to
// 'settings' for the true case keeps the forbidden tag unreachable
// from IPC.
// The `incomingOptedIn === false` narrow stops a compromised renderer synthesizing a spurious first_launch_banner opt-in.
if (existedBefore && currentOptedIn === null && incomingOptedIn === false) {
return 'first_launch_banner'
}
@@ -109,40 +53,20 @@ export function registerTelemetryHandlers(store: Store): void {
storeRef = store
ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => {
// Strict input typing: non-string names are dropped at the boundary
// before the validator even sees them. The validator would also drop
// (unknown event name), but the main-side narrow keeps the attack
// surface minimal — a flood of bogus payloads does not exercise the
// Zod parser for no reason.
// Drop non-string names at the boundary so a flood of bogus payloads never reaches the Zod validator.
if (typeof name !== 'string') {
return
}
// `props` may legitimately be omitted; treat `undefined`/`null` as an
// empty object before the validator. Anything else non-object (e.g.
// a string, a number) is a boundary violation.
// `props` is optional (undefined/null → {} below); reject any other non-object at the boundary.
if (props !== null && props !== undefined && typeof props !== 'object') {
return
}
const eventName = name as EventName
// Why: some event schemas are registered for main-owned emissions only.
// Letting renderer IPC emit them would let compromised content spoof
// product outcomes that must be tied to a successful main-side action.
// Why: these events are main-owned; renderer IPC emitting them would let compromised content spoof product outcomes.
if (MAIN_OWNED_TELEMETRY_EVENTS.has(eventName)) {
return
}
// Inject cohort here, at the IPC entry, only for events whose schemas
// declare `nth_repo_added` (see `COHORT_EXTENDED` in telemetry-events.ts).
// The selectivity is load-bearing: schemas are `.strict()`, so adding
// `nth_repo_added` to an event that does not declare it would fail Zod
// validation and silently drop the entire event. The renderer call sites
// stay synchronous (matching the existing fire-and-forget shape) and
// avoid an extra IPC round-trip to fetch cohort.
//
// Onboarding events get the same treatment for the `cohort` property,
// gated by `isOnboardingEvent` (events whose schema declares `cohort`).
// The two injection sets are disjoint by construction today — no schema
// declares both `nth_repo_added` and `cohort` — but combining them via
// spread keeps that an additive change rather than a structural one.
// Inject cohort props only for schemas that declare them: schemas are `.strict()`, so an extra prop on any other event fails Zod and drops it.
const baseProps = (props ?? {}) as Record<string, unknown>
const withRepoCohort = isCohortExtendedEvent(eventName)
? { ...baseProps, ...getCohortAtEmit() }
@@ -150,11 +74,7 @@ export function registerTelemetryHandlers(store: Store): void {
const finalProps = isOnboardingEvent(eventName)
? { ...withRepoCohort, ...getOnboardingCohortAtEmit() }
: withRepoCohort
// The casts to `EventName` / `EventProps<EventName>` here are
// pass-through only — this file does NOT pretend the renderer's
// name/props are type-safe. The validator inside `track()` is the
// single enforcement point at runtime; these casts only feed the
// typed channel that the validator will re-check.
// Casts are pass-through only; `track()`'s validator is the single runtime enforcement point, not these casts.
track(eventName, finalProps as EventProps<EventName>)
})
@@ -163,79 +83,39 @@ export function registerTelemetryHandlers(store: Store): void {
if (typeof optedIn !== 'boolean') {
return
}
// Check storeRef BEFORE consuming a consent-mutation token. If the store
// isn't ready (pre-registration race, test harness misuse, a future
// refactor), consuming a token here would burn budget for a no-op and
// eventually block legitimate mutations from the same session.
// Check storeRef before consuming a token — burning one on a no-op would eventually block legitimate mutations this session.
if (!storeRef) {
return
}
// Consent-mutation bucket: ≤5 per session. See `burst-cap.ts`. Does not
// apply to main-originated consent mutations that bypass IPC (none
// today; this is future-proofing rather than a current code path).
// Consent-mutation bucket: ≤5 per session (see `burst-cap.ts`).
if (!consumeConsentMutationToken()) {
return
}
// Read settings BEFORE any state mutation. The derivation must see the
// pre-mutation world so an existing user clicking "Turn off" on the
// notice still presents as (optedIn=null → false) at the moment `via`
// is computed, not (optedIn=false → false) after the write lands.
// Derive `via` BEFORE the write so it sees the pre-mutation state (optedIn still null for an existing user's "Turn off").
const via = deriveOptInVia(storeRef, optedIn)
return setOptIn(via, optedIn)
})
// Read-only view of the effective consent state. The Privacy pane needs
// this to render the correct helper text when an env var
// (DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / CI) blocks transmission —
// those variables are main-side process state and the renderer has no
// way to read them directly. No rate limit: this is a synchronous getter
// with no mutation, bounded in work by one `resolveConsent` call.
// Read-only getter: lets the Privacy pane see env-var blocks (DO_NOT_TRACK/ORCA_TELEMETRY_DISABLED/CI), which are main-side state the renderer can't read.
ipcMain.handle('telemetry:getConsentState', (): ConsentState => {
if (!storeRef) {
// Fail closed — a missing store means we cannot honor the user's
// stored preference, so surface pending_banner rather than a
// misleading 'enabled'. The renderer treats pending_banner like a
// disabled state in the UI.
// Fail closed: no store means we can't honor the stored preference, so surface pending_banner, not a misleading 'enabled'.
return { effective: 'pending_banner' }
}
return resolveConsent(storeRef.getSettings())
})
ipcMain.handle('telemetry:acknowledgeBanner', (_event): Promise<void> | void => {
// Banner ✕ persist `optedIn = true` without emitting a telemetry opt-in
// event. The acknowledge still unlocks `app_opened`, but this outcome
// cannot route through `telemetry:setOptIn` because the derivation above
// would tag it `first_launch_banner` and fire `telemetry_opted_in`.
//
// Check storeRef BEFORE consuming a consent-mutation token, mirroring
// the setOptIn handler's guard above — see that comment for why
// burning a token on a no-op blocks legitimate mutations later in
// the same session.
// Banner ✕: persist optedIn=true WITHOUT emitting — routing through setOptIn would fire telemetry_opted_in, which the silent-acknowledge contract forbids.
if (!storeRef) {
return
}
// State-precondition guard: this channel is ONLY valid when the notice
// is pending resolution — i.e. existing-user cohort
// (existedBeforeTelemetryRelease=true) with optedIn still null. Any
// other state is either a UI bug (the notice should not be reachable
// post-resolution) or a compromised renderer trying to silently flip
// optedIn=true after the user already opted out, which would bypass
// the audit signal entirely. `deriveOptInVia` already applies the
// symmetric guard on `telemetry:setOptIn` (it refuses to tag a true
// flip as `first_launch_banner`); this narrows the silent-persist
// attack surface on the acknowledge channel to exactly the state the
// notice contract covers. Must run BEFORE the token consume — same
// "don't burn a token on a no-op" reasoning as the !storeRef guard
// above.
// Only valid while the notice is pending (existedBefore=true, optedIn=null); any other state is a renderer silently flipping optedIn after opt-out.
const telemetry = storeRef.getSettings().telemetry
if (telemetry?.existedBeforeTelemetryRelease !== true || telemetry?.optedIn !== null) {
return
}
// This path still goes through `consumeConsentMutationToken` — a
// compromised renderer could otherwise burn through the
// acknowledgeBanner channel to force unbounded settings-file writes,
// which is a CPU/disk amplification vector even without any event
// emission.
// Rate-limit even this silent path: unbounded acknowledge calls are a disk-write amplification vector.
if (!consumeConsentMutationToken()) {
return
}
@@ -243,9 +123,7 @@ export function registerTelemetryHandlers(store: Store): void {
})
}
// Test-only reset for the module-level store reference. Tests can
// re-register handlers against a fresh mock store without leaking state
// between describes.
// Test-only reset so tests can re-register handlers without leaking store state between describes.
export function _resetStoreForTests(): void {
storeRef = null
}
+59 -188
View File
@@ -1,11 +1,5 @@
/* eslint-disable max-lines */
// Why: extracted from worktrees.ts to keep the main IPC module under the
// max-lines threshold. Worktree creation helpers (local and remote) live
// here so the IPC dispatch file stays focused on handler wiring. The
// sparse-checkout flow plus the post-create setup-runner wiring pushed
// this file marginally over the per-file limit; matches the
// eslint-disable pattern other files in src/renderer use when a
// cohesive flow would split awkwardly.
// Why: worktree create helpers (local + remote) split out of worktrees.ts; the cohesive create flow runs this file just over the per-file line limit.
import type { BrowserWindow } from 'electron'
import { posix, win32 } from 'node:path'
@@ -128,8 +122,7 @@ import {
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512
// Why: bound the create-path fallback `git fetch origin` so a Windows
// credential-manager GUI hang (STA-1292) can't wedge worktree creation forever.
// Why: bound the fallback `git fetch origin` so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation forever.
const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000
const sshWorktreeCreateFetchInflight = new Map<string, Promise<void>>()
const sshWorktreeCreateFetchCompletedAt = new Map<string, number>()
@@ -276,9 +269,7 @@ async function spawnLocalStartupAndSetupTerminals(args: {
}
try {
// Why: after `git worktree add` and metadata registration, a runtime-owned
// PTY can begin booting the selected agent while setup runs in a sibling
// terminal. Earlier than this, the worktree path is not yet safe for agents.
// Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside.
if (isTuiAgent(createdWithAgent)) {
const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust
try {
@@ -462,8 +453,7 @@ async function getOrStartSshWorktreeCreateFetch(
return
}
await fetch()
// Why: SSH creation has no OrcaRuntimeService instance to share, but
// repeated creates on the same target should still reuse recent fetches.
// Why: SSH creation has no OrcaRuntimeService to share; still reuse recent fetches for repeated creates on the same target.
rememberSshWorktreeCreateFetchCompletedAt(key)
}).finally(() => {
if (sshWorktreeCreateFetchInflight.get(key) === promise) {
@@ -520,8 +510,7 @@ async function unsetRemoteWorktreeCreationBase(
worktreePath
)
} catch {
// Best-effort SSH sparse cleanup; keep the sparse setup error as the
// actionable failure and let removeWorktree handle the partial checkout.
// Best-effort cleanup; keep the sparse setup error as the actionable failure.
}
}
@@ -686,10 +675,7 @@ async function hasRemoteWorktreeBaseRef(
return hasRemoteCommitObject(provider, repoPath, baseRef)
}
// Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is
// a symbolic ref (refs/remotes/origin/main), so detect its presence directly so
// SSH creates can fall back to an existing local base ref when the refresh
// fetch fails. Require a resolved object id: a missing ref exits non-zero.
// Why: hasRemoteCommitObject resolves only SHAs, not symbolic remote-tracking refs; detect those directly for the fetch-failed local fallback.
async function hasRemoteTrackingRefSsh(
provider: SshGitProvider,
repoPath: string,
@@ -798,8 +784,7 @@ async function hasSshRemoteBranchConflict(
if (isAllowedSshRemoteBaseRef(refName, allowedBaseRef)) {
return false
}
// Why: `git branch --all --list feature/x` does not match
// `remotes/origin/feature/x`; parse remote refs directly instead.
// Why: `git branch --all --list feature/x` doesn't match `remotes/origin/feature/x`; parse remote refs directly.
return resolveSshRemoteBranchName(refName, remoteNames) === branchName
})
} catch {
@@ -1126,8 +1111,7 @@ async function prepareWorktreePushTargetSsh(
const existingRemote = await findRemoteForUrlSsh(provider, repoPath, target.remoteUrl)
if (existingRemote) {
remoteName = existingRemote
// Why: if a later PR worktree reuses an Orca-created fork remote, it
// must inherit ownership so deleting the final user can remove it.
// Why: a reused Orca-created fork remote must inherit ownership so deleting the final user can remove it.
remoteCreated = store
? isPushTargetRemoteCreatedByKnownWorktree(
store,
@@ -1338,8 +1322,7 @@ export async function prefetchRemoteWorktreeCreateBase(
repo: Repo,
args: { baseBranch?: string }
): Promise<void> {
// Why: the shared base-plan probes use generic git.exec, and some relays
// require the repo root to be registered before those probes can see refs.
// Why: base-plan probes use generic git.exec, and some relays require the repo root registered before probes can see refs.
await registerOptionalSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
if (!basePlan) {
@@ -1355,13 +1338,11 @@ export async function prefetchRemoteWorktreeCreateBase(
}
}
if (await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) {
// Why: PR/MR resolvers already fetched verified SHA start points. A broad
// remote fetch only updates unrelated refs when the commit object exists.
// Why: PR/MR resolvers already fetched verified SHA start points; a broad fetch only updates unrelated refs.
return
}
// Why: mirrors createRemoteWorktree's legacy local-base fallback so
// prefetch and create share one process-local SSH fetch cache.
// Why: mirrors createRemoteWorktree's legacy local-base fallback so prefetch and create share one process-local SSH fetch cache.
await fetchRemoteForWorktreeCreate(provider, repo, 'origin')
}
@@ -1411,8 +1392,7 @@ async function evaluateRemoteLocalBaseRefRefreshability(
let behind = 0
try {
// Why: SSH generic git.exec is allowlisted. `merge-base` and `log` are
// allowed read-only probes; `rev-list` is intentionally not exposed there.
// Why: SSH generic git.exec is allowlistedmerge-base and log are permitted read-only probes; rev-list is intentionally not exposed.
await provider.exec(['merge-base', '--is-ancestor', fullRef, remoteTrackingBase.ref], repoPath)
const { stdout } = await provider.exec(
['log', '--format=%H', `${fullRef}..${remoteTrackingBase.ref}`],
@@ -1420,8 +1400,7 @@ async function evaluateRemoteLocalBaseRefRefreshability(
)
behind = countNonEmptyGitOutputLines(stdout)
if (!shouldInspectOwner(behind)) {
// Why: no behind commits means the advisory cannot offer an update;
// avoid remote worktree/status round trips that cannot change that.
// Why: no behind commits means no update to advise; skip remote worktree/status round trips.
return {
refreshable: true,
...resultBase,
@@ -1462,8 +1441,7 @@ async function evaluateRemoteLocalBaseRefRefreshability(
}
}
// Why: not checked out anywhere a bare ref fast-forward is safe. Omitting
// ownerWorktreePath tells the relay to update-ref instead of reset --hard.
// Why: not checked out anywhere, so a bare-ref fast-forward is safe; omitting ownerWorktreePath tells the relay to update-ref, not reset --hard.
return {
refreshable: true,
...resultBase,
@@ -1509,8 +1487,7 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
}
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
// Why: invalidate detected-worktree caches before renderer observers react,
// so follow-up listDetected reads post-change state.
// Why: invalidate detected-worktree caches before renderer observers react, so follow-up listDetected sees post-change state.
runWorktreeChangeInvalidators(repoId)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
@@ -1521,8 +1498,7 @@ export function notifyWorktreeGitStatusMetadataChanged(
mainWindow: BrowserWindow,
repoId: string
): void {
// Why: index churn is a Source Control freshness hint, not a worktree graph
// mutation; keep structural caches and runtime/mobile events untouched.
// Why: index churn is a Source Control freshness hint, not a graph mutation; leave structural caches and runtime/mobile events untouched.
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', { repoId })
}
@@ -1533,18 +1509,13 @@ export function notifyWorktreeHeadIdentitiesChanged(
repoId: string,
identities: WorktreeHeadIdentity[]
): void {
// Why: background worktrees have no active-scoped status refresh, so head
// moves detected from metadata files ride this targeted desktop event
// instead of re-entering the structural fanout or runtime/mobile events.
// Why: background worktrees have no active status refresh, so metadata-detected head moves ride this targeted event instead of the structural fanout.
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:headIdentitiesChanged', { repoId, identities })
}
}
// Why: two-phase spinner. Main process fires `'fetching'` before waiting on
// pre-create fetch work and `'creating'` immediately before `git worktree add`.
// Renderer swaps its spinner label in response; fallback is the static
// "Creating worktree..." label if no event arrives.
// Why: two-phase spinner fire 'fetching' before pre-create fetch and 'creating' before git worktree add so the renderer can swap its label.
export function emitCreateWorktreeProgress(
mainWindow: BrowserWindow,
phase: 'fetching' | 'creating',
@@ -1574,24 +1545,17 @@ export async function createRemoteWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: resolving the create base can probe repo refs through generic git.exec.
// Register the repo root first so relays do not report a valid base as stale.
// Why: base resolution probes refs via generic git.exec; register the repo root first so relays don't report a valid base as stale.
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
// Why: explicit branches and non-username prefix modes never consume this
// value; skipping the remote config probes preserves the exact branch name.
// Why: explicit branches and non-username prefix modes never consume this; skipping the remote probe preserves the exact branch name.
const username =
!args.branchNameOverride && settings.branchPrefix === 'git-username'
? await getSshGitUsername(provider, repo.path)
: ''
const branchConflictSubject = args.branchNameOverride ? 'branch name' : 'worktree name'
// Determine base branch
// Why: previously fell back to a hardcoded 'origin/main' when
// symbolic-ref failed. That silently handed addWorktree a ref that may
// not exist on the remote (e.g. repos whose primary branch is master or
// develop), producing an opaque git error. Fail here with a clear
// message so the UI can surface it and prompt the user to pick a base.
// Why: don't fall back to hardcoded 'origin/main'; it may not exist (master/develop) and yields an opaque git error, so fail clearly and let the UI prompt.
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
if (!basePlan) {
throw new Error(
@@ -1607,8 +1571,7 @@ export async function createRemoteWorktree(
let selectedExistingLocalBranchName: string | null = null
let lastBranchConflictKind: 'local' | 'remote' | null = null
let remotePathResolved = false
// Why: duplicate PR/MR checkouts still need a workspace; suffix the local
// branch/path while preserving the review metadata and push target.
// Why: duplicate PR/MR checkouts still need a workspace; suffix branch/path while preserving review metadata and push target.
for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = args.name.trim()
@@ -1630,8 +1593,7 @@ export async function createRemoteWorktree(
baseBranch
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: once a user-selected branch is safe to reuse, path retries should
// keep that branch exact instead of creating a sibling branch.
// Why: once a user-selected branch is safe to reuse, path retries keep it exact instead of creating a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
@@ -1705,8 +1667,7 @@ export async function createRemoteWorktree(
}
}
// Why: addWorktree and setup probes run inside the new worktree path; older
// relays need that root registered before accepting git/fs operations there.
// Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there.
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath])
if (remoteTrackingBase) {
@@ -1726,13 +1687,7 @@ export async function createRemoteWorktree(
try {
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
} catch {
// Why: a failed refresh must not block creation when a usable local base
// ref already exists — `git worktree add` can still create from that
// (possibly stale but valid) ref, so a transient offline/auth failure does
// not make the workspace uncreatable. Probe AFTER registerRoot so relays
// that gate generic git.exec accept it; only hard-fail when there is no
// local ref to fall back on. Drift is reflected by the compare-to-base
// view once the remote is reachable again.
// Why: a refresh failure shouldn't block create if a usable (stale) local base ref exists; probe after registerRoot and hard-fail only when none does.
if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
@@ -1740,9 +1695,7 @@ export async function createRemoteWorktree(
}
}
} else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) {
// Why: local or otherwise non-remote-tracking bases preserve legacy
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
// commit object locally, so a broad remote fetch only updates unrelated refs.
// Why: non-remote-tracking bases keep the legacy best-effort fetch; verified PR/MR SHA bases already have the object, so a broad fetch is wasted.
try {
await fetchRemoteForWorktreeCreate(provider, repo, 'origin')
} catch {
@@ -1775,8 +1728,7 @@ export async function createRemoteWorktree(
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
// Why: fork-PR SSH worktrees need the same contributor-remote setup as
// local worktrees before creation, otherwise Push/Sync can target origin.
// Why: fork-PR SSH worktrees need contributor-remote setup before create, else Push/Sync target origin.
preparedPushTarget = await prepareWorktreePushTargetSsh(
provider,
repo.path,
@@ -1786,7 +1738,6 @@ export async function createRemoteWorktree(
)
}
// Create worktree via relay
try {
await timing.time('git_worktree_add', async () =>
provider.addWorktree(
@@ -1804,12 +1755,7 @@ export async function createRemoteWorktree(
(err.message.includes('No workspace roots registered yet') ||
err.message.includes('Path outside authorized workspace'))
) {
// Why: only an OLD relay binary (pre-allowlist-removal) can produce
// these errors. New relays no-op session.registerRoot. Translate the
// raw error into an actionable upgrade-window message while still
// preserving the original string for bug reports. Tracked for removal
// once the relay-version floor moves past the cutover (see
// docs/relay-fs-allowlist-removal.md).
// Why: only OLD relays (pre-allowlist-removal) throw these; surface an upgrade message. Remove after version floor moves (docs/relay-fs-allowlist-removal.md).
throw new Error(
`Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`
)
@@ -1818,8 +1764,7 @@ export async function createRemoteWorktree(
}
if (sparseDirectories.length > 0) {
try {
// Why: SSH providers expose generic git exec, so the remote sparse flow
// can mirror local addSparseWorktree without adding a relay method.
// Why: SSH providers expose generic git exec, so remote sparse mirrors local addSparseWorktree without a new relay method.
await provider.exec(['sparse-checkout', 'init', '--cone'], remotePath)
await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath)
await provider.exec(['checkout', branchName], remotePath)
@@ -1830,8 +1775,7 @@ export async function createRemoteWorktree(
await provider
.removeWorktree(remotePath, true, {
deleteBranch: !checkoutExistingBranch,
// Why: sparse setup failed before the user could work in the new
// branch, so rollback should remove the just-created remote branch.
// Why: sparse setup failed before any work happened, so rollback removes the just-created remote branch.
forceBranchDelete: !checkoutExistingBranch
})
.catch(() => undefined)
@@ -1852,8 +1796,7 @@ export async function createRemoteWorktree(
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
// Control must compare against the review target branch.
// Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch.
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
@@ -1865,20 +1808,13 @@ export async function createRemoteWorktree(
)
}
const metaUpdates: Partial<WorktreeMeta> = {
// Why: path-derived worktree IDs can be reused after external deletion.
// Fresh creations must rotate instance identity so stale lineage cannot
// attach to the new occupant of the same path.
// Why: path-derived IDs get reused after external deletion; rotate instance identity so stale lineage can't attach to the new occupant.
instanceId: randomUUID(),
...(store.getProjectHostSetups
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
: {}),
lastActivityAt: now,
// Why: grants the new worktree a short grace window at the top of the
// Recent sort. During worktree creation (git fetch + add can take several
// seconds) other worktrees get ambient PTY bumps that would otherwise
// leave the newly-created one below them; the Recent comparator uses
// max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the
// window elapses. See smart-sort.ts `CREATE_GRACE_MS`.
// Why: grace window atop Recent so ambient PTY bumps on others during create don't bury the new worktree. See smart-sort.ts `CREATE_GRACE_MS`.
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'ssh',
@@ -1928,11 +1864,7 @@ export async function createRemoteWorktree(
})
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
// Why: shared paths are intentionally not wired up for remote (SSH)
// worktrees. Creating symlinks on the remote host would
// require a new relay method and authorization surface; the feature is
// local-only until that protocol work is in scope. Remote repos with
// `symlinkPaths` configured have them silently ignored here.
// Why: shared/symlink paths are local-only; remote (SSH) support needs a new relay method + auth surface, so configured symlinkPaths are ignored here.
let setup: CreateWorktreeResult['setup']
let defaultTabs: CreateWorktreeResult['defaultTabs']
@@ -1943,8 +1875,7 @@ export async function createRemoteWorktree(
try {
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision)
} catch (error) {
// Why: default tab commands share setup's run policy. If the target branch
// adds commands without a renderer decision, create the tabs but don't run them.
// Why: default tab commands share setup's run policy; without a renderer decision, create the tabs but don't run them.
console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error)
defaultTabs = yamlHooks?.defaultTabs
? { tabs: yamlHooks.defaultTabs, runCommands: false }
@@ -1956,9 +1887,7 @@ export async function createRemoteWorktree(
try {
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
} catch (error) {
// Why: the remote worktree already exists. If the created branch adds
// a setup hook without a renderer decision, skip setup instead of
// reporting successful git creation as failed.
// Why: worktree already exists; skip setup rather than fail a successful git create when the branch adds a hook without a renderer decision.
console.warn(`[hooks] setup hook skipped for ${created.path}:`, error)
}
}
@@ -2018,8 +1947,7 @@ export async function createLocalWorktree(
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: explicit branches and non-username prefix modes never consume this
// value; skipping the probes preserves the exact generated branch name.
// Why: explicit branches and non-username prefix modes never consume this; skipping the probe preserves the exact generated branch name.
const username =
!args.branchNameOverride && settings.branchPrefix === 'git-username'
? await resolveLocalGitUsername(repo.path)
@@ -2057,11 +1985,7 @@ export async function createLocalWorktree(
}
})
if (!baseBranch) {
// Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD,
// origin/main, origin/master, local main, or local master exist. Don't
// fall back to a hardcoded 'origin/main' — passing a non-existent ref to
// `git worktree add` produces an opaque error. Fail here with a clear
// message so the UI can prompt the user to pick a base branch explicitly.
// Why: no default base resolved; fail clearly rather than pass a hardcoded non-existent ref to git worktree add (opaque error) so the UI can prompt.
throw new Error(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
@@ -2107,10 +2031,7 @@ export async function createLocalWorktree(
} else if (
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
// Why: when the base branch does not match a configured remote prefix
// (e.g. plain `main`, `master`, or any local branch), the legacy path
// still ran a best-effort `git fetch origin`. Verified PR SHA bases
// already have the needed commit object, so skip that broad fetch.
// Why: non-remote-prefix bases (plain main/master/local) keep the legacy best-effort fetch; verified PR SHA bases already have the object.
legacyFetchPromise = runtime
.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs)
.then(() => undefined)
@@ -2132,8 +2053,7 @@ export async function createLocalWorktree(
}
const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings)
// Why: this validation does not depend on remote refs, so it can overlap a
// required remote-tracking base refresh.
// Why: this validation doesn't depend on remote refs, so it can overlap a required remote-tracking base refresh.
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
if (primarySetupScript) {
shouldRunSetupForCreate(repo, args.setupDecision)
@@ -2152,8 +2072,7 @@ export async function createLocalWorktree(
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
// Why: use Set-based comparison so directory order does not affect
// attribution — matches the renderer's sparseDirectoriesMatch logic.
// Why: Set-based compare so directory order doesn't affect attribution — matches renderer's sparseDirectoriesMatch.
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
@@ -2177,8 +2096,7 @@ export async function createLocalWorktree(
let lastBranchConflictKind: 'local' | 'remote' | null = null
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
let lastExistingReviewNumber: number | null = null
// Why: create-from-review can provide an exact branch override that already
// exists locally; suffix both branch and path instead of blocking the user.
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = requestedName.trim()
@@ -2203,8 +2121,7 @@ export async function createLocalWorktree(
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing branch checkout
// must keep using the user-selected branch instead of creating a sibling.
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
@@ -2252,12 +2169,7 @@ export async function createLocalWorktree(
continue
}
// Why: `gh pr list` is a network round-trip that previously ran on every
// create, adding ~13s to the happy path even when no conflict exists. We
// only probe PR conflicts once a local/remote branch collision has already
// forced us past the first suffix — at that point uniqueness matters
// enough to justify the GitHub call. The common case (brand-new branch
// name, no collisions) skips the network entirely.
// Why: gh pr list is a ~13s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
@@ -2288,9 +2200,7 @@ export async function createLocalWorktree(
}
if (!resolved) {
// Why: if every suffix in range collides, fall back to the original
// "reject with a specific reason" behavior so the user sees why creation
// failed instead of a generic error or (worse) an infinite spinner.
// Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner.
if (lastExistingReviewNumber !== null) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.`
@@ -2316,12 +2226,7 @@ export async function createLocalWorktree(
await timing.time('refresh_base_ref', async () => {
const result = await remoteTrackingRefresh.promise
if (!result.ok && !remoteTrackingRefresh.hadLocalBaseRef) {
// Why: only block creation when the refresh failed AND there is no local
// base ref to fall back on. An existing local remote-tracking ref lets
// `git worktree add` proceed from a possibly stale but valid base, so a
// transient offline/auth failure must not make the workspace
// uncreatable. The compare-to-base view reflects any drift once the
// remote is reachable again.
// Why: only block create when the refresh failed AND there's no local base ref; an existing (possibly stale) ref keeps worktree add viable.
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
)
@@ -2348,9 +2253,7 @@ export async function createLocalWorktree(
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
// Why: validate and fetch the contributor remote before creating the
// worktree. If this fails, retrying won't hit branch/path conflicts from a
// half-created worktree.
// Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry.
preparedPushTarget = await prepareWorktreePushTarget(
repo.path,
args.pushTarget,
@@ -2460,10 +2363,7 @@ export async function createLocalWorktree(
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
// Why: fork-PR review worktrees should publish commits back to the PR
// author's branch. Configure the branch upstream immediately so the
// existing Push/Pull/Sync controls use the contributor remote instead of
// silently defaulting to origin.
// Why: fork-PR review worktrees publish back to the PR author's branch; set upstream so Push/Sync use the contributor remote, not origin.
configuredPushTarget = await configureCreatedWorktreePushTarget(
worktreePath,
branchName,
@@ -2485,23 +2385,17 @@ export async function createLocalWorktree(
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
// Control must compare against the review target branch.
// Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch.
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
const metaUpdates: Partial<WorktreeMeta> = {
// Why: path-derived worktree IDs can be reused after external deletion.
// Fresh creations must rotate instance identity so stale lineage cannot
// attach to the new occupant of the same path.
// Why: path-derived IDs can be reused after external deletion; rotate instance identity so stale lineage can't attach to the new occupant.
instanceId: randomUUID(),
...(store.getProjectHostSetups
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
: {}),
// Stamp activity so the worktree sorts into its final position
// immediately — prevents scroll-to-reveal racing with a later
// bumpWorktreeActivity that would re-sort the list.
// Stamp activity so the worktree sorts into its final position immediately, avoiding a re-sort race with scroll-to-reveal.
lastActivityAt: now,
// See createRemoteWorktree above: createdAt protects the newly-created
// worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS.
// createdAt protects the new worktree from ambient PTY bumps for CREATE_GRACE_MS (see createRemoteWorktree above).
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
@@ -2550,17 +2444,13 @@ export async function createLocalWorktree(
return { worktree: mergeWorktree(repo.id, created, meta) }
})
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
// Why: creation already paid for `git worktree list`; seed the exact roots
// now so the next file/git IPC does not lazily rescan and trip macOS privacy
// prompts for the newly-created workspace.
// Why: reuse the roots creation already paid for via `git worktree list` so later IPC doesn't lazily rescan and trip macOS privacy prompts.
registerWorktreeRootsForRepo(store, repo.id, [
repo.path,
...gitWorktrees.map((worktree) => worktree.path)
])
// Why: materialize user-configured paths from the primary checkout into the
// new worktree before any setup script runs, so scripts that reuse shared
// state (e.g. `node_modules`, `.env`) see those paths already in place.
// Why: link user-configured shared paths (e.g. `node_modules`, `.env`) before setup runs so setup scripts see them in place.
const symlinkPaths = repo.symlinkPaths ?? []
if (symlinkPaths.length > 0) {
await timing.time('create_symlinks', async () => {
@@ -2568,15 +2458,7 @@ export async function createLocalWorktree(
})
}
// Why: the worktree's own `orca.yaml` (at the tip of the base branch) is
// authoritative for what runs post-creation. The repo-level trust already
// granted by the user in the pre-create flow covers execution of that
// script; we intentionally do not re-gate on content equality with the
// primary checkout's preview, because benign divergence (whitespace,
// comments, or any setup-script edit that has landed on the base branch
// but not yet been pulled into the primary checkout) was silently
// disabling setup with no UI signal. See #1280 for the original gate and
// the regression this replaced.
// Why: the worktree's base-branch `orca.yaml` is authoritative; we don't re-gate on content parity with the primary checkout since benign divergence silently disabled setup (#1280).
let setup: CreateWorktreeResult['setup']
let defaultTabs: CreateWorktreeResult['defaultTabs']
await timing.time('prepare_setup', async () => {
@@ -2585,8 +2467,7 @@ export async function createLocalWorktree(
try {
defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision)
} catch (error) {
// Why: default tab commands share setup's run policy. If the target branch
// adds commands without a renderer decision, create the tabs but don't run them.
// Why: default tab commands share setup's run policy; if the target branch adds commands without a renderer decision, create the tabs but don't run them.
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
defaultTabs = createdYamlHooks?.defaultTabs
? { tabs: createdYamlHooks.defaultTabs, runCommands: false }
@@ -2598,24 +2479,14 @@ export async function createLocalWorktree(
try {
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
} catch (error) {
// Why: if the target branch introduces setup hooks that the primary
// checkout did not expose, the renderer may not have collected an ask
// decision. The worktree already exists, so skip setup instead of
// turning successful git creation into an IPC failure.
// Why: target branch may add setup hooks the renderer never collected a decision for; worktree exists, so skip setup rather than fail creation.
console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error)
}
}
if (setupScript && shouldLaunchSetup) {
try {
// Why: setup now runs in a visible terminal owned by the renderer so users
// can inspect failures, answer prompts, and rerun it. The main process only
// resolves policy and writes the runner script; it must not execute setup
// itself anymore or we would reintroduce the hidden background-hook behavior.
//
// Why: the git worktree already exists at this point. If runner generation
// fails, surfacing the error as a hard create failure would lie to the UI
// about the underlying git state and strand a real worktree on disk.
// Degrade to "created without setup launch" instead.
// Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior.
// Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation.
setup = createSetupRunnerScript(
repo,
worktreePath,
+18 -59
View File
@@ -432,9 +432,7 @@ describe('registerWorktreeHandlers', () => {
getHostedReviewForBranchMock.mockResolvedValue(null)
getWorkItemMock.mockResolvedValue(null)
getPullRequestPushTargetMock.mockResolvedValue(null)
// Why: createLocalWorktree can still hit legacy git fetch fallback in
// narrow unit harnesses. Return a resolved promise so catch/then chains
// don't trip on undefined.
// Why: createLocalWorktree can still hit the legacy git fetch fallback here; resolve so catch/then chains don't trip on undefined.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
getEffectiveHooksMock.mockReturnValue(null)
getEffectiveHooksFromConfigMock.mockImplementation(() => getEffectiveHooksMock())
@@ -489,10 +487,7 @@ describe('registerWorktreeHandlers', () => {
listWorktreesMock.mockResolvedValue([])
forceDeleteLocalBranchMock.mockResolvedValue(undefined)
// Why: createLocalWorktree routes `git fetch` through
// `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub
// keeps these tests focused on create-flow semantics; the full
// cache behavior is covered by fetch-remote-cache.test.ts.
// Why: minimal stub keeps these tests on create-flow semantics; full fetchRemoteWithCache behavior is covered by fetch-remote-cache.test.ts.
runtimeStub = {
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
@@ -795,8 +790,7 @@ describe('registerWorktreeHandlers', () => {
worktreeId: 'repo-1::/workspace/feature-wt',
updates: { isUnread: false }
})
// Why: per-click isUnread writes must stay event-free (PR #209), while a
// rename must reach paired remote clients that no longer poll for titles.
// Why: per-click isUnread writes must stay event-free (PR #209), while a rename must reach paired remote clients that no longer poll for titles.
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled()
handlers['worktrees:updateMeta'](null, {
@@ -842,8 +836,7 @@ describe('registerWorktreeHandlers', () => {
})
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
// rather than failing and forcing the user back to the name picker.
// Why: new-workspace flow should silently try improve-dashboard-2, -3, … rather than failing back to the name picker.
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard' ? 'remote' : null
)
@@ -1236,9 +1229,7 @@ describe('registerWorktreeHandlers', () => {
})
it('reuses an existing local branch when the worktree folder is renamed (#5181)', async () => {
// Why: the reuse checkbox keeps branchNameOverride pinned to the selected
// branch while the worktree folder is named independently. The backend must
// still check out that exact branch (no -b) into the renamed folder.
// Why: reuse keeps branchNameOverride on the selected branch though the folder is renamed; backend must check out that branch (no -b).
listWorktreesMock
.mockResolvedValueOnce([
{
@@ -4026,8 +4017,7 @@ describe('registerWorktreeHandlers', () => {
})
it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => {
// Regression: a stale persisted repo base must fall back to the detected
// primary default instead of blocking creation.
// Regression: a stale persisted repo base must fall back to the detected primary default instead of blocking creation.
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -4901,8 +4891,7 @@ describe('registerWorktreeHandlers', () => {
})
it('creates from the detected default base when the persisted base is stale', async () => {
// Regression: a stale persisted repo base must fall back to the detected
// primary default instead of blocking creation.
// Regression: a stale persisted repo base must fall back to the detected primary default instead of blocking creation.
const remoteBase = {
remote: 'origin',
branch: 'main',
@@ -5206,11 +5195,7 @@ describe('registerWorktreeHandlers', () => {
})
it('throws a clear error when no default base ref can be resolved', async () => {
// Why: guard against regressing to a silent 'origin/main' fallback. When
// default-base resolution returns null (e.g. a fresh repo with no origin/HEAD,
// no origin/main, no origin/master, and no local main/master), we must
// fail loudly with a message that prompts the user to pick a base
// branch, not hand a non-existent ref to `git worktree add`.
// Why: guard against regressing to a silent 'origin/main' fallback; an unresolved default base must fail loudly, not hand a non-existent ref to `git worktree add`.
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null)
store.getRepo.mockReturnValue({
id: 'repo-1',
@@ -5588,10 +5573,7 @@ describe('registerWorktreeHandlers', () => {
})
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
// (e.g. a folder repo just added, or a pre-existing worktree in a
// newly-added git repo) would otherwise fall back to `lastActivityAt: 0`
// and rank dead last in the Recent sort.
// Why: a worktree on disk with no persisted WorktreeMeta would otherwise fall back to lastActivityAt: 0 and rank dead last in Recent.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/discovered-wt',
@@ -5631,9 +5613,7 @@ describe('registerWorktreeHandlers', () => {
})
it('backfills project-host ownership without re-stamping lastActivityAt for existing meta', async () => {
// Why: only the *first* discovery should stamp. Re-stamping on every list
// would overwrite real activity and reshuffle the sidebar on refresh. Host
// ownership can still be filled because it is derived from the repo setup.
// Why: only first discovery stamps (re-stamping would reshuffle the sidebar); host ownership is still backfilled since it derives from repo setup.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/existing-wt',
@@ -5685,9 +5665,7 @@ describe('registerWorktreeHandlers', () => {
})
it('repairs legacy project ids when discovery now resolves the same host setup to a logical project', async () => {
// Why: provider identity can become available after metadata was written.
// Existing workspaces should move from repo-scoped IDs to the logical
// project ID without losing activity ordering.
// Why: provider identity can arrive after metadata was written; existing workspaces must move to the logical project ID without losing activity ordering.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/existing-wt',
@@ -5948,9 +5926,7 @@ describe('registerWorktreeHandlers', () => {
})
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
// Why: folder repos produce a synthetic worktree that flows through the
// same list path. Without the stamp, adding a folder puts its card at the
// bottom of Recent even though the user just added it.
// Why: folder repos produce a synthetic worktree; without the stamp a just-added folder sorts to the bottom of Recent.
store.getRepos.mockReturnValue([
{
id: 'repo-1',
@@ -5991,9 +5967,7 @@ describe('registerWorktreeHandlers', () => {
})
it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => {
// Why: the stamping logic lives in both worktrees:list and worktrees:listAll.
// Without a dedicated test, a regression in the listAll loop would silently
// bury newly-discovered worktrees from the multi-repo sidebar view.
// Why: stamping logic is duplicated in worktrees:list and worktrees:listAll; a listAll regression would silently bury newly-discovered worktrees.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/discovered-wt',
@@ -6022,9 +5996,7 @@ describe('registerWorktreeHandlers', () => {
})
it('omits prunable worktrees from worktrees:listAll', async () => {
// Why: a prunable registration has no working directory (issue #8389), so
// surfacing it as a workspace yields repeated pty:spawn/fs:readDir
// failures and a blank pane.
// Why: a prunable registration has no working directory (issue #8389), so surfacing it yields repeated pty/fs failures and a blank pane.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/repo',
@@ -6138,13 +6110,7 @@ describe('registerWorktreeHandlers', () => {
})
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
// Why: `gh pr list` is network-bound and previously fired on every single
// create, adding 13s to the happy path. We now only probe PR conflicts
// from suffix=2 onward — once a local/remote branch collision has already
// forced us past the first candidate and uniqueness matters enough to
// justify the GitHub round-trip. This test covers that delayed path:
// suffix=1 is a branch conflict, suffix=2 is owned by an old PR, so the
// loop lands on suffix=3.
// Why: the PR-conflict probe (network-bound, 13s) only runs from suffix=2 onward, after a branch collision already forced past the first candidate.
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard' ? 'remote' : null
)
@@ -6192,10 +6158,7 @@ describe('registerWorktreeHandlers', () => {
})
it('does not call `gh pr list` on the happy path (no branch conflict)', async () => {
// Why: guards the speed optimization. If a future refactor accidentally
// reintroduces the PR probe on the first iteration, the happy path will
// silently regain a 13s GitHub round-trip per click; this test fails
// loudly instead.
// Why: guard against a refactor reintroducing the PR probe on the happy path (13s GitHub round-trip per click).
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
@@ -6317,10 +6280,7 @@ describe('registerWorktreeHandlers', () => {
})
it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => {
// Why: regression for a silent skip introduced by the #1280 content-equality
// gate. Benign divergence (whitespace, comments, or any setup edit that
// landed on the base branch but not yet in the primary checkout) must not
// disable setup — repo-level trust already gates execution.
// Why: benign orca.yaml divergence must not disable setup (regression from #1280 content-equality gate); repo trust already gates execution.
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockImplementation((_repo, worktreePath?: string) => ({
scripts: {
@@ -8748,8 +8708,7 @@ describe('registerWorktreeHandlers', () => {
const worktreeId = 'repo-1::/workspace/feature-wt'
store.getRepo.mockReturnValue(repo)
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
// Why: a removed/disconnected SSH target has no live provider; forgetLocal
// must not reach for one.
// Why: a removed/disconnected SSH target has no live provider; forgetLocal must not reach for one.
getSshGitProviderMock.mockReturnValue(undefined)
const result = await handlers['worktrees:forgetLocal'](null, { worktreeId })
+51 -157
View File
@@ -146,8 +146,7 @@ function getRepoForWorktreeRemoval(
const matches = store
.getRepos()
.filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
// Why: deletion must never guess between host owners. Legacy unscoped calls
// remain compatible only while the repo id still has one unique owner.
// Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner.
if (matches.length === 1) {
return matches[0]
}
@@ -219,16 +218,13 @@ async function mapWithConcurrency<T, R>(
}
function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: string): void {
// Why: worktree IDs are path-derived and can be recreated, so removal must
// drop process-local caches before the same ID can point at a new workspace.
// Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace.
store.removeWorktreeMeta(worktreeId)
advertisedUrlWatcher.forgetWorktree(worktreeId)
// Why: drop this worktree's localhost label routes so they don't accumulate
// in the proxy's route maps for the rest of the session.
// Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session.
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
deleteWorktreeHistoryDir(worktreeId)
// Why: release the removed worktree's PR-refresh aliases so coalesced queue
// entries do not retain it for the rest of the session (memory creep).
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
pruneWorktreePRRefreshAliases(worktreeId)
}
@@ -242,8 +238,7 @@ function getProjectHostSetupMetaUpdates(
existing?.projectHostSetupId === undefined ||
existing.projectHostSetupId === ownership.projectHostSetupId
return {
// Why: project IDs can be upgraded from legacy repo IDs to provider-backed
// logical IDs. If the host setup is the same, repair ownership on discovery.
// Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches.
...(sameSetup && existing?.projectId !== ownership.projectId
? { projectId: ownership.projectId }
: {}),
@@ -254,11 +249,7 @@ function getProjectHostSetupMetaUpdates(
}
}
// Why: worktrees discovered on disk (not created via Orca's UI) have no
// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`.
// That makes them sort to the bottom of "Recent" even though the user just
// added the repo / folder. The same authoritative discovery pass is also the
// safest time to backfill project-host setup ownership for upgraded profiles.
// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here.
function resolveWorktreeMetaWithDiscoveryBackfill(
store: Store,
repo: Repo,
@@ -272,9 +263,7 @@ function resolveWorktreeMetaWithDiscoveryBackfill(
...ownershipUpdates
}
if (Object.keys(updates).length > 0) {
// Why: profiles created before lineage shipped already have WorktreeMeta
// rows. Backfill on authoritative discovery so upgraded workspaces can
// immediately participate in instance-validated lineage and host routing.
// Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing.
return store.setWorktreeMeta(worktreeId, updates)
}
return existing
@@ -479,8 +468,7 @@ function getPreservedBranchCleanupTarget(
const loggedUnavailableSshGitProviders = new Set<string>()
const loggedWorktreeListFailures = new Set<string>()
const loggedMalformedWorktreeMetaKeys = new Set<string>()
// Why: absorb renderer polling bursts while keeping external worktree-change
// lag bounded to one short refresh window.
// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window.
const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000
type DetectedWorktreeScanCacheEntry = {
@@ -513,8 +501,7 @@ function invalidateDetectedWorktreeScanCache(repoId: string): void {
detectedWorktreeScanCache.delete(key)
const inFlight = detectedWorktreeScanInFlight.get(key)
if (inFlight) {
// Why: the detached scan keeps this token, so later scans can settle
// without making an older result fresh again.
// Why: the detached scan keeps this token so later scans settle without making an older result fresh again.
inFlight.invalidated = true
detectedWorktreeScanInFlight.delete(key)
}
@@ -524,8 +511,7 @@ function invalidateDetectedWorktreeScanCache(repoId: string): void {
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
export function __resetDetectedWorktreeScanCacheForTests(): void {
// Why: scans still pending across a test reset must not repopulate the
// cache afterward and leak state into the next test.
// Why: pending scans across a test reset must not repopulate the cache and leak state into the next test.
for (const scan of detectedWorktreeScanInFlight.values()) {
scan.invalidated = true
}
@@ -573,8 +559,7 @@ async function listDetectedGitWorktrees(
detectedWorktreeScanInFlight.set(cacheKey, scan)
try {
const gitWorktrees = await scan.promise
// Why: a create/remove notification can invalidate while the git scan is
// still running. Do not let that stale scan repopulate the cache afterward.
// Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward.
if (!scan.invalidated) {
detectedWorktreeScanCache.set(cacheKey, {
worktrees: gitWorktrees,
@@ -615,9 +600,7 @@ function rememberLocalWorktreeRoots(
if (repo.connectionId) {
return
}
// Why: worktrees:list already paid the `git worktree list` cost. Reusing
// that result keeps later git/file IPC validation from doing a second
// background scan that can trigger macOS folder-permission prompts.
// Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts.
registerWorktreeRootsForRepo(store, repo.id, [
repo.path,
...gitWorktrees.map((worktree) => worktree.path)
@@ -651,20 +634,14 @@ function pruneLineageForMissingRepoWorktrees(
}
for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
// Why: path-derived IDs can disappear and later be reused by a different
// checkout. Once a successful scan proves the child is gone, drop its
// lineage so a future same-path worktree cannot inherit it. Missing
// parents stay readable so the UI can show the repairable "Missing
// parent" state.
// Why: path-derived IDs can be reused; once a scan proves the child is gone, drop its lineage so a future same-path worktree can't inherit it.
store.removeWorktreeLineage(childId)
store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId))
}
if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) {
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
// Why: keep the child lineage so the UI can show "Missing parent", but
// rotate the absent parent's stale identity once. If a different
// checkout later reuses that path, the old lineage stays invalid.
// Why: keep child lineage for the "Missing parent" UI, but rotate the absent parent's identity once so a path reuse can't inherit it.
store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() })
}
}
@@ -751,8 +728,7 @@ function buildDetectedGitWorktrees(
const settings = store.getSettings()
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo)
// Why: a prunable registration has no working directory (issue #8389); only
// this listing omits it — removal/cleanup flows list worktrees separately.
// Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately.
const liveWorktrees = gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable)
return dedupeWorktreesByPath(liveWorktrees).map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
@@ -992,8 +968,7 @@ export function registerWorktreeHandlers(
store: Store,
runtime: OrcaRuntimeService
): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
// Remove previously registered handlers so re-register works when macOS re-activates and creates a new window.
ipcMain.removeHandler('worktrees:listAll')
ipcMain.removeHandler('worktrees:list')
ipcMain.removeHandler('worktrees:listDetected')
@@ -1021,8 +996,7 @@ export function registerWorktreeHandlers(
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
: new Map()
// Why: each local repo listing can spawn `git worktree list`; cap fan-out
// so large repo fleets don't start unbounded subprocesses at once.
// Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses.
const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => {
try {
let gitWorktrees
@@ -1071,13 +1045,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
// Why: do NOT seed an empty success here. registerWorktreeRootsForRepo
// would mark this repo as registered and flip
// registeredWorktreeRootsDirty to false, which causes
// resolveRegisteredWorktreePath to permanently deny access to
// legitimate linked worktrees of this repo until something invalidates
// the cache. Leaving it unregistered keeps the cache dirty so the
// next access path can rebuild.
// Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated.
return []
}
})
@@ -1141,8 +1109,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
// Why: see worktrees:listAll catch — seeding an empty-success result
// would poison the auth cache and block linked worktrees.
// Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees.
return []
}
})
@@ -1232,8 +1199,7 @@ export function registerWorktreeHandlers(
try {
await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime })
} catch {
// Why: this is an optimistic warm-up. The actual create path still
// awaits the same refresh and reports user-visible failures there.
// Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there.
}
}
)
@@ -1241,14 +1207,7 @@ export function registerWorktreeHandlers(
ipcMain.handle(
'worktrees:create',
async (_event, args: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
// Why span here: worktree creation chains a clone-or-checkout, an
// install hook, and several git invocations. Wrapping the IPC entry
// gives every child git span a parent to attach to, so a failure in
// step 3 of 5 still shows up in the trace tree alongside steps 12.
// The branch name and remote URL are intentionally not added as
// attributes — branch names can carry user-content (e.g. an issue
// title) and the redactor would have to learn yet another rule;
// the repo ID is the safer correlator for the bundle.
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
return withWorktreeSpan({ stage: 'create' }, async () => {
const repo = store.getRepo(args.repoId)
if (!repo) {
@@ -1271,11 +1230,7 @@ export function registerWorktreeHandlers(
let result: CreateWorktreeResult
try {
// Why: only wrap the helpers themselves. The pre-validation throws
// above (`Repo not found`, `Folder mode does not support creating
// worktrees`) signal IPC-shape bugs, not the user-visible
// git/filesystem failures the funnel cares about — bucketing them
// into `unknown` would pollute the failure taxonomy.
// Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks.
result = isFolderRepo(repo)
? createFolderWorkspace(createArgs, repo, store)
: repo.connectionId
@@ -1292,14 +1247,7 @@ export function registerWorktreeHandlers(
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
// Why: emit `workspace_created` only after the underlying create has
// resolved (the helpers throw on failure, so reaching this line means
// git-add succeeded — we deliberately do not also emit a separate
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
// `from_existing_branch` is true iff the caller specified a non-empty
// baseBranch; an unspecified baseBranch means "branch from default
// HEAD", which is the not-from-existing-branch case. We never send
// the branch name itself.
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
source,
from_existing_branch:
@@ -1349,8 +1297,7 @@ export function registerWorktreeHandlers(
}
return provider.exec(args, repo.path)
}
// Why: SSH repos can't fetch over the relay's read-only git.exec channel, so
// route the PR head fetch through the write-capable helper instead of gitExec.
// Why: SSH repos can't fetch over the relay's read-only git.exec channel; route the PR-head fetch through the write-capable helper.
const fetchRemoteTrackingRef = (remote: string, branch: string): Promise<void> =>
fetchPrHeadTrackingRef(
repo,
@@ -1386,8 +1333,7 @@ export function registerWorktreeHandlers(
}
)
// Why: keep desktop IPC and mobile/runtime RPC on the same MR base
// resolution path so SSH repos do not regress differently by surface.
// Why: keep desktop IPC and mobile/runtime RPC on the same MR-base path so SSH repos don't regress differently per surface.
ipcMain.handle(
'worktrees:resolveMrBase',
async (
@@ -1436,9 +1382,7 @@ export function registerWorktreeHandlers(
throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`)
}
// Why: stale toast actions, double-clicks, and Space/sidebar races can
// target the same worktree concurrently. Share the destructive backend
// operation so only one path touches Git and the filesystem.
// Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk.
const removal = (async (): Promise<RemoveWorktreeResult> => {
if (isFolderRepo(repo)) {
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
@@ -1446,8 +1390,7 @@ export function registerWorktreeHandlers(
'Cannot delete the project root workspace. Remove the folder project instead.'
)
}
// Why: folder workspaces share one filesystem root, so there is no Git
// remove step to close shells; sweep PTYs before dropping metadata.
// Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata.
await killAllProcessesForWorktree(args.worktreeId, {
runtime,
localProvider: getLocalPtyProvider(),
@@ -1461,8 +1404,7 @@ export function registerWorktreeHandlers(
return {}
}
// Why: the renderer-supplied worktreeId contains a filesystem path.
// Re-derive the canonical path from git before any destructive action.
// Why: renderer-supplied worktreeId embeds a path; re-derive the canonical path from git before any destructive action.
const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
const localWorktreeGitOptions = repo.connectionId
? {}
@@ -1614,13 +1556,10 @@ export function registerWorktreeHandlers(
}
if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) {
if (!args.force && !removedMeta) {
// Why: without persisted metadata, require the renderer recovery
// path before deleting Orca-only state for an unregistered path.
// Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path.
throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE)
}
// Why: a manually deleted worktree is already gone from Git and disk.
// The sidebar delete action has persisted metadata proving this was
// an Orca-known row, so no force confirmation is needed.
// Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed.
if (repo.connectionId) {
await cleanupUnusedWorktreePushTargetRemoteSsh(
provider!,
@@ -1650,8 +1589,7 @@ export function registerWorktreeHandlers(
const canonicalWorktreePath = registeredWorktree.path
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
// Why: a Git lock must block before archive hooks or linked-path cleanup
// mutate the workspace; dirty-file force is a separate permission.
// Why: a Git lock must block before archive hooks or linked-path cleanup mutate the workspace; dirty-file force is separate.
try {
assertWorktreeUnlockedForRemoval(registeredWorktree)
} catch (error) {
@@ -1660,8 +1598,7 @@ export function registerWorktreeHandlers(
)
}
// Why: a prior forced Windows recovery can delete the directory but leave
// Git's stale registration; recover and verify it before clearing metadata.
// Why: a prior forced Windows recovery can delete the dir but leave a stale Git registration; verify before clearing metadata.
if (
!repo.connectionId &&
args.force === true &&
@@ -1719,8 +1656,7 @@ export function registerWorktreeHandlers(
}
if (repo.connectionId) {
// Why: SSH deletion mirrors the local flow: hooks run while the
// directory is intact, then the clean check guards destructive removal.
// Why: SSH deletion mirrors the local flow hooks run while the directory is intact, then the clean check guards removal.
if (!args.force) {
const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath)
if (!clean) {
@@ -1783,8 +1719,7 @@ export function registerWorktreeHandlers(
)
}
try {
// Why: an archive hook can race another Git client that locks the row;
// recheck before linked-path, watcher, or terminal teardown side effects.
// Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown.
assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree)
} catch (error) {
throw new Error(
@@ -1815,30 +1750,25 @@ export function registerWorktreeHandlers(
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
)
}
// Why: Git can still classify this as an orphan after preflight;
// retain strict PTY teardown before any recursive fallback deletion.
// Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion.
}
let removalResult: RemoveWorktreeResult | undefined
const removalGate = await runtime.acquireFileWatcherRemoval(canonicalWorktreePath)
let removalCompleted = false
try {
// Why: preflight ignores only these configured paths without mutating
// the worktree; keep new watcher installs fenced through Git removal.
// Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal.
if (linkedPaths.length > 0) {
await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths)
}
// Why: hold the watcher/terminal gate until Git and any recursive
// fallback complete, so no late spawn can recreate a native handle.
// Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle.
await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId)
try {
const removeOptions = {
...(!deleteBranch ? { deleteBranch } : {}),
// Why: this handler already paid for an authoritative worktree
// list to validate the target; reuse it instead of rescanning
// every sibling worktree during the hot delete path.
// Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path.
knownRemovedWorktree: refreshedRegisteredWorktree,
...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {})
}
@@ -1852,8 +1782,7 @@ export function registerWorktreeHandlers(
refreshedRegisteredWorktree.head
)
} catch (error) {
// Why: Git for Windows can deregister a clean worktree before its
// recursive filesystem deletion fails transiently.
// Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently.
const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({
error,
force: args.force ?? false,
@@ -1890,10 +1819,7 @@ export function registerWorktreeHandlers(
`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}`
)
}
// Why: `git worktree remove` failed, so git's internal worktree tracking
// (`.git/worktrees/<name>`) is still intact. Without pruning, `git worktree
// list` continues to show the stale entry and the branch it had checked out
// remains locked — other worktrees cannot check it out.
// Why: remove failed so git still tracks it (.git/worktrees/<name>); prune or the stale entry keeps its branch locked.
await gitExecFileAsync(['worktree', 'prune'], {
cwd: repo.path,
...localWorktreeGitOptions
@@ -1953,12 +1879,7 @@ export function registerWorktreeHandlers(
}
)
// Why: forget-locally drops a workspace from Orca without any remote Git or
// filesystem work. It exists so a workspace pinned to a removed/disconnected
// SSH target — whose provider is gone and whose `worktrees:remove` therefore
// throws at requireSshGitProvider before any cleanup runs — can still be
// cleared from the app. It never touches the remote: no worktree registration,
// no branches, no files are deleted there.
// Why: drop a workspace locally with no remote work, so one pinned to a dead SSH target (where worktrees:remove throws) can still be cleared.
ipcMain.handle(
'worktrees:forgetLocal',
async (
@@ -1970,9 +1891,7 @@ export function registerWorktreeHandlers(
if (!repo) {
throw new Error(`Repo not found: ${repoId}`)
}
// Why: share the removal in-flight map (not a separate one) so a concurrent
// worktrees:remove and worktrees:forgetLocal on the same id cannot both
// mutate metadata. A forget takes no force/skipArchive options.
// Why: share the removal in-flight map so concurrent remove and forgetLocal on the same id can't both mutate metadata.
const inFlightKey = getWorktreeRemovalInFlightKey(
args.worktreeId,
getRepoExecutionHostId(repo)
@@ -1993,9 +1912,7 @@ export function registerWorktreeHandlers(
)
}
// Why: best-effort PTY sweep. killAllProcessesForWorktree resolves
// synchronously for a dead SSH relay (the provider tombstones the lease
// and returns without awaiting the remote), so this never hangs.
// Why: best-effort PTY sweep; resolves synchronously for a dead SSH relay (tombstoned lease) so it never hangs.
await killAllProcessesForWorktree(args.worktreeId, {
runtime,
localProvider: getLocalPtyProvider(),
@@ -2043,9 +1960,7 @@ export function registerWorktreeHandlers(
if (repo.connectionId) {
const provider = requireSshGitProvider(repo.connectionId)
// Why: SSH must use the write-capable relay RPC; the shared exec-based
// helper routes through the read-only git.exec allowlist, which rejects
// the worktree/update-ref/config writes this delete needs.
// Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes.
await provider.forceDeletePreservedBranch(
repo.path,
cleanupTarget.branchName,
@@ -2095,18 +2010,9 @@ export function registerWorktreeHandlers(
}
: args.updates
const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates))
// Do NOT call notifyWorktreesChanged here. The renderer applies meta
// updates optimistically before calling this IPC, so a notification
// would trigger a redundant fetchWorktrees round-trip that bumps
// sortEpoch and reorders the sidebar — the exact bug PR #209 tried
// to fix (clicking a card would clear isUnread → updateMeta →
// worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort).
// Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209).
if (args.updates.displayName !== undefined) {
// Why: paired remote clients have no optimistic copy of the rename and
// no longer poll for titles, so push the remote-only invalidation.
// Gated on displayName to keep isUnread-per-click updates event-free.
// getRepoIdFromWorktreeId matches the mobile client's event filter and
// never throws after the meta write already succeeded.
// Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free.
runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId))
}
return meta
@@ -2137,27 +2043,19 @@ export function registerWorktreeHandlers(
}
)
// Why: the renderer continuously snapshots the computed sidebar order into
// sortOrder so that it can be restored on cold start (when ephemeral signals
// like running jobs and live terminals are gone). A single batch call avoids
// N individual updateMeta IPC round-trips; the persistence layer debounces
// the actual disk write.
// Why: snapshot sidebar order for cold-start restore (ephemeral signals gone); one batch call avoids N updateMeta IPCs.
ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => {
// Defensive: guard against malformed or missing input from the renderer.
if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) {
return
}
const now = Date.now()
for (let i = 0; i < args.orderedIds.length; i++) {
// Descending timestamps so that the first item has the highest
// sortOrder value (most recent), making b.sortOrder - a.sortOrder
// a natural "first wins" comparator on cold start.
// Descending timestamps: first item gets highest sortOrder so b - a sorts first-wins on cold start.
store.setWorktreeMeta(args.orderedIds[i], { sortOrder: now - i * 1000 })
}
})
// Why: the full generation-failure output is main-memory only (never in
// worktree metadata), so the rename-failed dialog pulls it on demand.
// Why: full failure output lives only in main memory (not worktree metadata), so the dialog pulls it on demand.
ipcMain.handle(
'worktrees:getBranchRenameFailureOutput',
(_event, args: { worktreeId: string }) => {
@@ -2174,8 +2072,7 @@ export function registerWorktreeHandlers(
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
if (!repo) {
const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId)
// Why: a requested or ambiguous host must not be reported as hook-free;
// callers treat inspection errors as "skip", which keeps hook execution fail closed.
// Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free.
return {
status: args.hostId || repoIdExists ? 'error' : 'ok',
hasHooks: false,
@@ -2212,10 +2109,7 @@ export function registerWorktreeHandlers(
const has = hasHooksFile(repo.path)
const hooks = has ? loadHooks(repo.path) : null
// Why: when a newer Orca version adds a top-level key to `orca.yaml`, older
// versions that don't recognise it return null and show "could not be parsed".
// Detecting well-formed but unrecognised keys lets the UI suggest updating
// instead of implying the file is broken.
// Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed".
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
return {
status: 'ok',
+21 -74
View File
@@ -1,20 +1,11 @@
/**
* Boot-time hydration of `pty-registry` from the live daemon.
*
* Why: the registry is normally populated by the `pty:spawn` IPC
* handler. On warm reattach (a fresh Orca process bound to a
* still-running daemon), the renderer hasn't re-mounted every pane
* yet, so `pty:spawn` hasn't fired for those sessions and the memory
* collector's snapshot omits them. The renderer then unions in
* `pty.listSessions()` results with `hasLocalSamples: false`, which
* the chip predicate rendered as "REMOTE" even though the sessions
* are local.
*
* This module fills the gap once at boot: ask the daemon for every live
* session, reattribute each one to its repo via the minted session-id
* format, and only register sessions whose repo has no `connectionId`
* (i.e. truly local). Truly remote (SSH) sessions stay out of the
* registry, mirroring the spawn-time gate (the `if (!args.connectionId)` block around the `registerPty` call in `src/main/ipc/pty.ts`).
* Why: on warm reattach (fresh Orca process, still-running daemon) the renderer
* hasn't re-mounted every pane, so `pty:spawn` never fired for those sessions
* and they surfaced as "REMOTE" despite being local. Fill the gap once at boot,
* registering only sessions whose repo has no `connectionId` mirroring the
* spawn-time gate in `src/main/ipc/pty.ts`.
*/
import { getDaemonProvider } from '../daemon/daemon-init'
@@ -28,31 +19,15 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { splitWorktreeId } from '../../shared/worktree-id'
import type { Store } from '../persistence'
// Why: `attachMainWindowServices` runs on every macOS dock re-activation
// (see `app.on('activate', ...)` in src/main/index.ts), so this module
// guards against re-running git I/O + daemon RPC after the first pass.
// Stays false until we actually have a daemon provider, so a boot where
// the daemon socket isn't up yet remains retry-eligible on later
// re-activations.
// Why: attachMainWindowServices reruns on every macOS dock re-activation; guard against re-running git I/O + daemon RPC.
let hasHydrated = false
/**
* Read the live daemon session list and register every local session
* the registry doesn't already know about.
* Read the live daemon session list and register every local session the
* registry doesn't already know about.
*
* Once-per-process when the daemon is reachable on first call:
* `attachMainWindowServices` fires on every macOS dock re-activation, so
* the module-level `hasHydrated` guard ensures the git-worktree
* enumeration and `listSessions` daemon RPC only run on the first
* successful invocation. If the daemon is offline at first call (no
* provider yet), the function returns without flipping the flag so a
* later macOS re-activation can retry; once a provider is obtained the
* flag flips and subsequent calls are a no-op.
*
* Wrapped in `try/catch` because the daemon socket may be unreachable
* at boot (process not yet started, or just died); the renderer-side
* union still covers that case until the daemon comes back. Any failure
* here is a coverage degradation, not a correctness regression.
* Why: failures here are a coverage degradation (the renderer-side union still
* covers the gap), not a correctness regression, so they're swallowed.
*/
export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos'>): Promise<void> {
try {
@@ -61,36 +36,22 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
}
const provider = getDaemonProvider()
if (!provider) {
// Why: leave hasHydrated false so a later activation (after the
// daemon comes up) can retry.
// Why: leave hasHydrated false so a later activation can retry once the daemon is up.
return
}
// Why: flip only once we have a provider — committed to either
// succeeding or failing on a daemon RPC. Retrying after an RPC
// throw uses the same socket and is unlikely to help; the
// renderer-side union still covers that case.
// Why: flip only after a provider exists; retrying a failed RPC on the same socket won't help.
hasHydrated = true
// Why: ask the daemon which repos matter before launching Git worktree
// enumeration. Most configured repos have no preserved session at boot,
// so scanning all of them creates pure background subprocess churn.
// Why: defer git worktree enumeration to only repos with a live session; scanning all repos is background subprocess churn.
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
// Why: live git enumeration verifies that a referenced local worktree
// still exists instead of resurrecting removed worktrees.
// Why: verify via live git that a referenced worktree still exists, to avoid resurrecting removed ones.
const liveLocalWorktreeIds = new Set<string>()
const resolvedRepoIds = new Set<string>()
let sessionInfos = await collectSessionInfos(provider)
let alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
// Why: repo selection and registration must come from the same daemon
// snapshot. Git enumeration can take seconds, so after each scan pass we
// re-read daemon and registry state; sessions that exited or were
// authoritatively registered meanwhile are not resurrected or overwritten,
// and a session that only became visible during a slow scan (e.g. a
// briefly unreachable legacy adapter) gets its repo scanned on the next
// pass instead of being silently dropped. Terminates because every pass
// permanently resolves at least one new repo id.
// Why: git enumeration is slow, so re-read daemon+registry each pass to avoid resurrecting exited sessions; terminates as each pass resolves >=1 new repo.
for (;;) {
const newlyReferencedRepos = new Map<string, ReturnType<(typeof store)['getRepos']>[number]>()
for (const info of sessionInfos) {
@@ -104,9 +65,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
}
const repo = reposById.get(parsedWorktreeId.repoId)
if (!repo || (repo.connectionId ?? null)) {
// Why: unknown repos can't be proven local, and SSH PTYs are never
// registered for local process sampling — resolve without git
// enumeration so neither can extend the loop.
// Why: unknown or SSH repos can't be proven local; resolve without git enumeration so they can't extend the loop.
resolvedRepoIds.add(parsedWorktreeId.repoId)
continue
}
@@ -128,10 +87,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
}
for (const info of sessionInfos) {
// Why: pid-write ordering — `pty:spawn` is the authoritative
// writer for in-session sessions; if that fired before this loop
// started, we must not overwrite a known-good pid with a stale one
// from listSessions(). Skip if the entry already exists.
// Why: pty:spawn is the authoritative pid writer; don't overwrite its entry with a stale listSessions() pid.
if (alreadyRegistered.has(info.sessionId)) {
continue
}
@@ -139,11 +95,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
if (!worktreeId) {
continue
}
// Why: SSH sessions must stay out of the registry — mirrors the
// spawn-time `if (!args.connectionId)` gate around `registerPty` in
// `src/main/ipc/pty.ts`. If the repo isn't in the store, skip the
// session: we can't prove it's local, and the renderer-side union
// still surfaces the session at the cost of a missing pid sample.
// Why: only register proven-local worktrees, mirroring the spawn-time !connectionId gate in src/main/ipc/pty.ts.
if (!liveLocalWorktreeIds.has(worktreeId)) {
continue
}
@@ -169,9 +121,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
async function collectSessionInfos(
provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
): Promise<SessionInfo[]> {
// Why: the router fans `listSessions` out across current + legacy adapters
// so we get every protocol-version daemon's sessions; the bare-adapter
// fallback is only the in-process restart edge case.
// Why: fan listSessions across current + legacy adapters so no daemon protocol version is missed.
const adapters: readonly DaemonPtyAdapter[] =
provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider
? provider.getAllAdapters()
@@ -180,15 +130,12 @@ async function collectSessionInfos(
for (const adapter of adapters) {
try {
const sessions = await adapter.listSessions()
// Why: warm reattach can discover many daemon sessions at once; spreading
// listSessions() into push can exceed JavaScript's argument limit.
// Why: session count can exceed the JS argument limit, so avoid push(...sessions).
for (const session of sessions) {
out.push(session)
}
} catch (err) {
// Why: a single adapter failing should not abort hydration of the
// others — the current adapter and any legacy daemons each have
// their own socket and one being unreachable is normal.
// Why: one adapter's socket being unreachable is normal; don't abort the others.
console.warn(
'[memory] listSessions failed for one adapter during hydration:',
err instanceof Error ? err.message : String(err)
+23 -80
View File
@@ -1,32 +1,10 @@
// Diagnostic bundle collection + upload (Mode 3 from
// telemetry-error-tracking.md). The single user-initiated network path from
// the error-tracking lane to Orca infrastructure. Every step here implements
// a hardening requirement from §Endpoint contract — the comments name the
// requirement number when they apply.
//
// Lifecycle:
// 1. `collectBundle()` — read the last N minutes of NDJSON across the
// rotated family, run the redactor a second time over the merged
// payload (belt-and-suspenders), embed the per-bundle
// `bundle_submission_id`. NEVER carries `install_id` (Issue 8 in the
// security review).
// 2. (renderer) — preview the bundle as plain text. User can copy or cancel.
// Main retains the uploadable payload so renderer cannot substitute
// arbitrary bytes after preview.
// 3. `uploadBundle()` — two-step:
// a) POST `/diagnostics/token` → token + upload_url
// b) POST `<upload_url>` with `Authorization: Bearer <token>` and the
// collected NDJSON payload. Returns ticket ID.
// 4. (renderer) — surface the support reference ID; offer copy/delete
// controls. Delete posts only the server-issued ID.
//
// Server-side endpoint contract is fully specified in
// telemetry-error-tracking.md §Endpoint contract. Implementation of those
// endpoints (token issuance, rate limit, storage, server-side redaction,
// retention, deletion) is operational TBD — flagged as an open question to
// the human dispatching this task. We ship the *client* of that contract
// with all hardening invariants the client controls (content-type pinning,
// body-size cap on upload, token-handling discipline).
// Diagnostic bundle collection + upload (Mode 3, telemetry-error-tracking.md): the one
// user-initiated network path from the error-tracking lane to Orca infra. The per-bundle
// submission ID NEVER carries install_id (security-review Issue 8), and main retains the
// uploadable payload so a compromised renderer can't substitute bytes after preview.
// Server endpoint contract lives in telemetry-error-tracking.md §Endpoint contract; we
// ship only the client, with the hardening invariants it controls (content-type pinning,
// upload body-size cap, token-handling discipline).
import { randomBytes } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
@@ -39,8 +17,7 @@ const DEFAULT_LOOKBACK_MINUTES = 30
export type CollectBundleOptions = {
readonly traceFilePath: string
readonly maxFiles: number
/** Detached-daemon lifecycle log. Its rotated family is merged into the
* bundle so daemon-side failures are diagnosable from a field report. */
/** Detached-daemon lifecycle log; its rotated family is merged in so daemon failures are diagnosable from a field report. */
readonly daemonLogFilePath?: string
readonly daemonLogMaxFiles?: number
readonly lookbackMinutes?: number
@@ -52,8 +29,7 @@ export type CollectBundleOptions = {
}
export type CollectedBundle = {
/** 128-bit unguessable random ID, base64url. NOT the install_id
* bundles are deliberately join-incompatible with the PostHog lane. */
/** 128-bit unguessable base64url ID. NOT the install_id — bundles are join-incompatible with the PostHog lane. */
readonly bundleSubmissionId: string
/** UTF-8 NDJSON payload — header line + N redacted span lines. */
readonly payload: string
@@ -91,11 +67,8 @@ function* readLinesNewestFirst(text: string): Iterable<string> {
}
/**
* Read the last N minutes of NDJSON across the rotated family and produce
* a redacted bundle payload. Caller renders this as preview text; main keeps
* the uploadable payload and `uploadBundle()` ships only those collected
* bytes. This keeps compromised renderer code from substituting arbitrary
* upload content after preview.
* Read the last N minutes of NDJSON across the rotated family into a redacted bundle payload.
* Main keeps the uploadable payload so a compromised renderer can't substitute bytes after preview.
*/
export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000
@@ -116,21 +89,11 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
const headerLine = JSON.stringify({ type: 'bundle-header', ...header })
const lines: string[] = [headerLine]
let spanCount = 0
// Running byte counter for the eventual payload. Starts with the header
// plus its final newline; each pushed span adds its line plus newline.
// Avoids re-running `lines.join('\n').length` every iteration — that's
// O(N²) in span count and dominates collection time for large backlogs.
// Track bytes incrementally to avoid an O(N²) `lines.join('\n').length` per span.
let currentBytes = Buffer.byteLength(`${headerLine}\n`)
const maxRecordBytes = MAX_BUNDLE_BYTES - currentBytes
// Files from listRotatedFiles are newest → oldest. Reading newest first
// means the cutoff filter naturally bounds our work — once we hit a span
// older than the cutoff in an older file we can stop entirely. We don't
// optimize that yet; the worst case (10 × 10 MB = 100 MB scan) takes
// <1 s on a modern SSD and bundles are user-initiated, not hot-path.
// Trace spans first (the primary payload), then the daemon lifecycle log.
// Daemon records carry an ISO `ts` instead of `endTimeUnixNano`; both are
// filtered by the same lookback below.
// Trace files then daemon log, each newest → oldest so the byte cap keeps the most recent spans.
const files = [
...listRotatedFiles(opts.traceFilePath, opts.maxFiles),
...(opts.daemonLogFilePath
@@ -140,10 +103,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
outer: for (const file of files) {
let text: string
try {
// statSync first to skip absurdly-large files defensively. The sink
// caps at 10 MB per file; a tampered file could theoretically be
// bigger, in which case we want to abort the bundle rather than
// panic-allocate.
// stat first: the sink caps at 10 MB/file, so a tampered oversize file could panic-allocate on read.
const size = statSync(file).size
if (size > 50 * 1024 * 1024) {
continue
@@ -153,9 +113,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
continue
}
// NDJSON parsing — one record per line. Process each file newest-first
// so the size cap preserves the spans closest to the support action.
// Skip malformed lines silently; a crash can leave a half-line.
// Newest-first so the size cap preserves the most recent spans; skip malformed lines (a crash can leave a half-line).
for (const raw of readLinesNewestFirst(text)) {
let parsed: unknown
try {
@@ -171,40 +129,32 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
endTimeUnixNano?: string
ts?: string
}
// Filter by end-time, not start-time. A long-lived span started 35
// minutes ago but ending inside the lookback is exactly what we want
// in the bundle for diagnosing "session crashed at minute 32."
// Filter by end-time, not start-time, so long-lived spans that ended inside the lookback are still included.
if (typeof record.endTimeUnixNano === 'string') {
try {
if (BigInt(record.endTimeUnixNano) < cutoffNanos) {
continue
}
} catch {
// Non-numeric end-time keep it; better to over-include than to
// drop a record we couldn't classify.
// Non-numeric end-time: keep it better to over-include than drop an unclassifiable record.
}
} else if (typeof record.ts === 'string') {
// Daemon lifecycle lines timestamp with an ISO `ts`; bound them by the
// same lookback window. Unparseable timestamps are kept (over-include).
// Daemon lifecycle lines use an ISO `ts`; unparseable timestamps are kept (over-include).
const tsMs = Date.parse(record.ts)
if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
continue
}
}
// Run the redactor a SECOND TIME over the parsed shape, in server mode.
// This catches nested auth-bearing fields and strips product-telemetry
// identity keys before the user's eyes hit the preview window.
// Second redaction pass (server mode) catches nested auth fields and strips identity keys before preview.
const redacted = JSON.stringify(redactValue(parsed, 'server'))
const redactedBytes = Buffer.byteLength(redacted) + 1
if (redactedBytes > maxRecordBytes) {
// One pathological record should not suppress every smaller recent
// span behind it. Skip records that cannot fit in an empty payload.
// Skip a single oversized record so it can't suppress every smaller span behind it.
continue
}
if (currentBytes + redactedBytes > MAX_BUNDLE_BYTES) {
// Hard ceiling at the same 4 MiB the upload endpoint enforces.
// Check before appending so the preview can be uploaded as-is.
// Hard ceiling matches the upload endpoint's 4 MiB; check before appending so the preview uploads as-is.
break outer
}
lines.push(redacted)
@@ -224,16 +174,9 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
// ── Bundle submission ID ─────────────────────────────────────────────────
/**
* 128-bit cryptographic random, URL-safe base64. Generated per bundle
* NOT persisted. A user submitting two bundles produces two unrelated IDs.
* This is the primary structural mitigation for Issue 8 (bundle
* install_id correlation).
*/
/** 128-bit URL-safe-base64 random, per-bundle and NOT persisted — mitigation for Issue 8 (bundle ↔ install_id correlation). */
export function generateBundleSubmissionId(): string {
// 16 bytes = 128 bits base64url is 22 chars (no padding). Matches the
// §Endpoint contract requirement that ticket IDs be unguessable and
// non-enumerable; we use the same shape for the submission ID.
// 16 bytes = 128 bits, base64url = 22 chars; unguessable/non-enumerable per §Endpoint contract.
return randomBytes(16)
.toString('base64')
.replace(/\+/g, '-')
+18 -58
View File
@@ -1,25 +1,8 @@
// NDJSON sink with size-based file rotation. Spans are serialized one per
// line to a primary file, and when that file's byte budget is exceeded the
// sink rolls it forward (`main.trace.ndjson` → `main.trace.ndjson.1` →
// `main.trace.ndjson.2` → … → `main.trace.ndjson.N`, oldest deleted).
//
// Defaults match the local-first trace sink design: 10 MB × 10 files. 100 MB
// is the worst-case footprint on a user's disk, keeping the sizing bounded
// without adding a network dependency.
//
// Two design constraints worth calling out:
//
// 1. Synchronous writes by default. The error-tracking lane has to be
// durable on crash — if the renderer or main process is about to die,
// a buffered async flush is exactly what we don't want. We use the
// `appendFileSync` path (cheap on modern fs at this volume) and
// explicitly do a final `flush()` on shutdown.
//
// 2. Buffered batches with a flush threshold. Batches of up to
// `FLUSH_BUFFER_THRESHOLD` lines are coalesced into one syscall to
// keep the per-span cost low; a periodic interval flushes the partial
// batch every `batchWindowMs` so a sparse-trace session still ends up
// on disk. Both knobs are configurable for tests.
// NDJSON trace sink with size-based rotation (`main.trace.ndjson` → `.1` → … → `.N`, oldest deleted).
// Defaults 10 MB × 10 files bound the on-disk footprint at ~100 MB with no network dependency.
// Writes are synchronous so the error-tracking lane survives a crash — an async buffered flush is
// exactly what we don't want when main/renderer is about to die. Lines batch into one syscall, with
// a periodic `batchWindowMs` flush so sparse-trace sessions still land on disk.
import {
chmodSync,
@@ -84,19 +67,13 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
const batchWindowMs = opts.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS
const flushThreshold = opts.flushBufferThreshold ?? DEFAULT_FLUSH_BUFFER_THRESHOLD
// Local traces can contain paths and crash context; keep them readable only
// by the current user even on systems with permissive default umasks.
// Traces hold paths and crash context; lock to current-user regardless of umask.
const traceDirectory = dirname(filePath)
mkdirSync(traceDirectory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE })
chmodPathIfPresent(traceDirectory, PRIVATE_DIRECTORY_MODE)
tightenTraceFamilyPermissions(filePath, maxFiles)
// The sink owns one open fd. The fd is recreated on rotation; the rotation
// routine closes the old fd, renames the file, and opens a fresh one. We
// use the fd directly (rather than `appendFileSync(filePath, ...)`) so
// rotation is a clean swap and so we can rely on `fstatSync` for the
// current-file size — `statSync(filePath)` would race against another
// process truncating the file under us.
// Hold the fd directly (not `appendFileSync`) so fstatSync sizing can't race another process truncating the file.
let fd: number = openAppend(filePath)
let currentBytes: number = safeFstatSize(fd)
@@ -118,23 +95,19 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
try {
return fstatSync(handle).size
} catch {
// Fresh-open or fd-out-of-band start from zero. The next write will
// size correctly via `currentBytes += chunk.length`.
// fstat failed (fresh-open / out-of-band fd); start at 0 — the next write re-sizes.
return 0
}
}
function rotate(): void {
// Close the active fd before renaming. Some filesystems (notably CIFS)
// refuse to rename an open file; we close, rename, then reopen.
// Close fd before rename: some filesystems (notably CIFS) refuse to rename an open file.
try {
closeSync(fd)
} catch {
/* swallow — best-effort */
}
// Cascade rename: `.N-1` → `.N`, `.N-2` → `.N-1`, …, base → `.1`.
// Walking from highest index down ensures we never overwrite a file we
// are about to rotate.
// Cascade base → `.1` → … → `.N`, walking highest index down so we never overwrite a file we still need.
for (let i = maxFiles - 1; i >= 1; i--) {
const src = i === 1 ? filePath : `${filePath}.${i - 1}`
const dst = `${filePath}.${i}`
@@ -143,9 +116,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
}
try {
if (existsSync(dst)) {
// The destination shouldn't exist after a clean rotation, but if
// we're recovering from a crashed prior session, drop stale
// intermediate files rather than failing the rename.
// Stale dst left by a crashed prior session; drop it rather than fail the rename.
unlinkSync(dst)
}
renameSync(src, dst)
@@ -176,8 +147,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
writeSync(fd, chunk)
currentBytes += chunkBytes
} catch {
// Reopen and retry once. If the second write also fails, drop this
// chunk — the error-tracking lane must never crash main.
// Reopen + retry once; if that also fails, drop the chunk — telemetry must never crash main.
try {
// Best-effort close of the prior fd to prevent fd-leak on transient errors.
try {
@@ -203,17 +173,13 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
for (const line of lines) {
const lineBytes = Buffer.byteLength(line, 'utf8')
if (lineBytes > maxBytes) {
// A single pathological span should not violate the documented
// maxFiles × maxBytes disk envelope. Drop only that record, not the
// rest of the buffered batch.
// Oversized single span would blow the maxFiles × maxBytes envelope; drop just this record.
continue
}
if (pendingChunkBytes > 0 && currentBytes + pendingChunkBytes + lineBytes > maxBytes) {
flushPendingChunk()
}
// Rotation point: if writing this line would exceed the cap and we
// already have something in the file, rotate first. Empty-file rotations
// are skipped (would just produce zero-byte `.N` files on a new install).
// Skip empty-file rotations (currentBytes > 0) so a new install never produces zero-byte `.N` files.
if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) {
rotate()
}
@@ -231,9 +197,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
timer = null
flushBuffer()
}, batchWindowMs)
// Don't keep the event loop alive purely for the flush timer — quitting
// is the path that already triggers a final synchronous flush via
// `close()`, and the periodic flush is a "while running" optimization.
// unref so the flush timer can't keep the process alive; close() does the final flush on quit.
if (typeof timer.unref === 'function') {
timer.unref()
}
@@ -249,9 +213,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
try {
line = `${JSON.stringify(record)}\n`
} catch {
// Circular reference / non-serializable. The redactor handles cycles
// for us; a stray here means the caller pushed something pre-redact.
// Drop rather than crash — the local file is best-effort.
// Redactor handles cycles upstream; a throw here means pre-redact data slipped in — drop rather than crash (best-effort).
return
}
buffer.push(line)
@@ -287,8 +249,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
}
}
/** Total byte usage across the rotated file family. Used by `bundle.ts` to
* size the read buffer and by the Privacy pane to display a footprint hint. */
/** Total byte usage across the rotated file family (read-buffer sizing + Privacy footprint hint). */
export function getRotatedFamilySize(
filePath: string,
maxFiles: number = DEFAULT_MAX_FILES
@@ -307,8 +268,7 @@ export function getRotatedFamilySize(
return total
}
/** List rotated files in age order (newest oldest) for `bundle.ts` to
* iterate when collecting the last N minutes of traces. */
/** Rotated files in age order (newest → oldest) for `bundle.ts` trace collection. */
export function listRotatedFiles(filePath: string, maxFiles: number = DEFAULT_MAX_FILES): string[] {
const out: string[] = []
for (let i = 0; i < maxFiles; i++) {
+31 -136
View File
@@ -1,59 +1,19 @@
// Secrets scrubber for the error-tracking lane. Runs synchronously at three
// well-defined locations (see telemetry-error-tracking.md §The redactor):
// Secrets scrubber for the error-tracking lane (see telemetry-error-tracking.md
// §The redactor). Runs at three locations — sink-write, bundle-collection, and
// server-ingest; the server pass is defense-in-depth since the client runs on an
// attacker-controllable binary, and it additionally drops PostHog identity keys.
//
// 1. Sink-write time — every span is redacted before NDJSON serialization.
// 2. Bundle-collection time — a second pass before the user-preview window
// renders. Belt-and-suspenders against a sink-write bug.
// 3. Server-side ingest — a third pass. The client-side redactor runs on
// an attacker-controllable binary; server-side redaction is the
// defense-in-depth guarantee on the one path where bundle bytes reach
// Orca infrastructure. We expose `serverSideRedact()` separately so
// the server can additionally drop `install_id`/`installId`/
// `distinct_id` keys (which are valid in product telemetry but must not
// ride along on a bundle — see "Why bundles do not carry install_id").
// The five rule families run in order; the string passes are idempotent, which
// is what makes the three-location placement safe.
//
// Five rule families, applied in this order:
// 1. labeled key-value (`api_key:`, `Authorization=Bearer …`)
// 2. provider-key fingerprints (8 shapes)
// 3. URL userinfo strip (`https://user:pass@host` → `https://[redacted]@host`)
// 4. .env-shape line redaction (`FOO_SECRET=…`)
// 5. attribute-key block-list (drop key entirely)
//
// Rules 14 operate on string values; rule 5 drops attribute *keys* before
// the values are even examined. The string passes are idempotent — running
// the redactor twice in a row produces the same output as running it once,
// which is what makes the three-location placement safe.
//
// Per-attribute length capping is deliberately NOT applied here. The spec
// argues against it (see §The redactor "No per-attribute length cap"):
// envelope-level bounds (10 MB × 10 file rotation; 4 MiB bundle upload cap)
// already cover the worst case, and a per-attribute truncation would eat the
// tail of long stack chains, which is the most diagnostic part. Spans that
// dump a multi-MB blob into one attribute are a call-site bug to fix at the
// call site, not at the sink.
// No per-attribute length cap: envelope bounds already cap size, and truncation
// would eat the tail of long stack chains — the most diagnostic part.
// Word boundaries (`\b`) on the keyword alternation prevent the rule from
// firing inside compound identifiers — e.g. `FOO_SECRET=…` (an .env-shape
// line redacted by Rule 4) and `DB_PASSWORD=…` should NOT match the
// `secret`/`password` keyword here, otherwise this rule would steal the
// match from rule 4 and produce `FOO_[redacted:labeled-kv]` rather than
// preserving the key name.
//
// The value alternation `(?:Bearer\s+\S+|Token\s+\S+|\S+)` lets the rule
// consume the *whole* secret-bearing segment for the common
// `Authorization=Bearer <jwt>` / `Authorization: Token <pat>` shapes — a
// plain `\S+` would only eat `Bearer` and leave the JWT exposed.
// `\b` stops this from stealing rule-4's `FOO_SECRET=` matches; the value alternation eats the whole `Bearer <jwt>`/`Token <pat>` segment.
const LABELED_KV =
/\b(?:api[-_]?key|token|secret|password|bearer|authorization)\b\s*[:=]\s*(?:Bearer\s+\S+|Token\s+\S+|\S+)/gi
// Each provider shape replaced with a tagged token so triage can see WHAT
// was redacted (e.g. `[redacted:anthropic-key]` is a strong hint that the
// failing call was a Claude auth error) without exposing the key itself.
//
// Order: longest / most-specific patterns first. `sk-ant-…` must be tried
// before the bare `sk-…` OpenAI shape, otherwise the Anthropic key would be
// partially matched by the OpenAI rule and the `[redacted:anthropic-key]`
// triage signal would be lost.
// Tagged tokens let triage see what was redacted without the key. Order is most-specific-first: `sk-ant-` before `sk-`, or the Anthropic tag is lost.
const PROVIDER_PATTERNS: { tag: string; re: RegExp }[] = [
{ tag: 'anthropic-key', re: /sk-ant-[a-zA-Z0-9_-]{40,}/g },
{ tag: 'openai-key', re: /sk-(?:proj-)?[a-zA-Z0-9_-]{32,}/g },
@@ -70,43 +30,18 @@ const PROVIDER_PATTERNS: { tag: string; re: RegExp }[] = [
{ tag: 'slack-token', re: /xox[baprsoe]-[A-Za-z0-9-]{10,}/g },
{
tag: 'pem',
// Greedy intentionally bounded by the matching END marker; PEM blocks are
// multi-line. The `[\s\S]+?` keeps it minimal so a buffer with two PEM
// blocks back-to-back redacts each one independently rather than gobbling
// text between them.
// Lazy `[\s\S]+?` so two back-to-back PEM blocks redact independently, not as one gobbled span.
re: /-----BEGIN [A-Z ]+-----[\s\S]+?-----END [A-Z ]+-----/g
}
]
// Userinfo strip — preserves host + path so the debug context (`failed to
// fetch from github.com/foo/bar`) is intact while removing the credential.
// Two shapes are valid in practice and both leak credentials:
// - `https://user:pass@host/...` (classic basic-auth URL)
// - `https://<token>@github.com/...` (GitHub PAT-in-URL — exactly what
// `git clone` emits when push/pull fails. No colon, just a token before
// the `@`.)
// The pattern matches either: any non-empty `[^/@\s]+@` after the scheme is
// userinfo and gets stripped. Spec mentions only the colon-bearing form,
// but the bare-token form is the one we actually see in failing git stderr.
// Strip URL userinfo — both `user:pass@` and bare-token `<pat>@` (seen in failing git stderr); keep host+path for debug context.
const URL_USERINFO = /(https?:\/\/)([^/@\s]+)@/g
// Per-line .env shape. The `m` flag is required so `^` anchors at line
// starts inside multi-line strings (a stack frame, a captured stderr
// dump, etc.). The pattern intentionally requires the equals sign on the
// same line — `FOO=\n bar` is a different pattern (continuation) and not
// commonly how secrets show up.
//
// The value pattern (`\S.*`) consumes to end of line so multi-token values
// like `FOO_TOKEN=Bearer <jwt>` are redacted whole rather than leaking the
// trailing token. The leading `\S` requires the value to start with a
// non-whitespace char so a bare `FOO=` followed by nothing on the same
// line doesn't get an empty redact-token.
// Per-line .env shape. `m` anchors `^` in multi-line strings; `\S.*` redacts the whole value (so `FOO=Bearer <jwt>` can't leak its tail), leading `\S` skips empty `FOO=`.
const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*\S.*/gm
// Attribute keys that must never carry through, regardless of value. Match
// is case-insensitive — HTTP headers vary in case and we want all forms.
// `Object.hasOwn` semantics: presence in this set drops the attribute
// entirely, the value is never examined.
// Attribute keys dropped regardless of value. Matched case-insensitively since HTTP headers vary in case.
const CLIENT_ATTR_BLOCKLIST = new Set([
'env',
'environment',
@@ -127,11 +62,7 @@ const CLIENT_ATTR_BLOCKLIST = new Set([
'headers.authorization'
])
// Mode-3 server-side pass adds the PostHog-lane identity keys. These are
// valid in product telemetry but must not ride along inside a bundle —
// otherwise an Orca staff member opening the bundle could re-identify all
// PostHog history for that user (see telemetry-error-tracking.md §"Why
// bundles do not carry install_id").
// Identity keys: valid in telemetry but stripped from bundles to prevent re-identifying PostHog history (see telemetry-error-tracking.md).
const SERVER_ATTR_BLOCKLIST_EXTRA = new Set([
'install_id',
'installid',
@@ -147,9 +78,7 @@ function shouldDropAttributeKey(key: string, mode: RedactorMode): boolean {
if (CLIENT_ATTR_BLOCKLIST.has(k)) {
return true
}
// Structured span attributes often carry secret labels in the key itself
// (`ANTHROPIC_API_KEY`, `clientSecret`, `x-api-key`) with plain values that
// string redaction cannot classify. Drop by key family before value redaction.
// Drop by key family: keys like `ANTHROPIC_API_KEY`/`x-api-key` carry plain values string redaction can't classify.
if (
/\b(api[-_]?key|token|secret|password|bearer|authorization|private[-_]?key)\b/i.test(key) ||
/(apikey|token|secret|password|authorization|bearer|privkey|privatekey)/.test(normalized)
@@ -162,54 +91,34 @@ function shouldDropAttributeKey(key: string, mode: RedactorMode): boolean {
return false
}
/**
* Apply rules 14 to a string. Idempotent running this twice yields the
* same output as once, which is what makes triple-application safe.
*/
/** Apply rules 14 to a string. Idempotent, which makes triple-application safe. */
export function redactString(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
return input
}
let out = input
// Rule 1 — labeled key-value. Replace the entire `key: value` segment with
// a tagged token. We deliberately blow away the labeled-key alongside the
// value because the label name itself ("api_key", "Authorization") leaks
// no useful debug context once the value is gone.
// Rule 1 — labeled key-value. Drop the key alongside the value; the label name adds no debug context once the value is gone.
out = out.replace(LABELED_KV, '[redacted:labeled-kv]')
// Rule 2 — provider-key fingerprints. Each shape is tried independently,
// so a string carrying multiple keys gets all of them redacted. The tag
// names (e.g. `anthropic-key`) are stable wire identifiers — third-party
// tools that read our NDJSON can grep for them.
// Rule 2 — provider-key fingerprints. Tag names (`anthropic-key`) are stable wire identifiers third-party NDJSON tools grep for.
for (const { tag, re } of PROVIDER_PATTERNS) {
out = out.replace(re, `[redacted:${tag}]`)
}
// Rule 3 — URL userinfo. Preserves scheme + host + path; replaces only the
// `user:pass@` segment with `[redacted]@`. Done after rule 2 so a userinfo
// value that happens to look like a provider key gets the more specific
// redaction first.
// Rule 3 — URL userinfo. After rule 2 so a key-shaped userinfo value gets the more specific redaction first.
out = out.replace(URL_USERINFO, '$1[redacted]@')
// Rule 4 — .env-shape line redaction. Keep the key name (`FOO_SECRET=`),
// replace only the value with `[redacted:env-value]`. Done last among the
// string passes so a labeled-kv match (rule 1) wins over a coincidentally
// .env-shaped substring inside a longer line.
// Rule 4 — .env-shape line: keep key, redact value. Last so rule 1 wins over a coincidentally .env-shaped substring.
out = out.replace(ENV_LINE, (_match, key) => `${String(key)}=[redacted:env-value]`)
return out
}
/**
* Recursively redact a value of unknown shape strings get rules 14;
* objects/arrays/maps recurse; primitives pass through. Designed for the
* span-attribute and span-event use cases where attribute *values* can be
* any JSON-shaped thing.
*
* Loop guard: we track visited references in a `WeakSet` so a self-referential
* cycle does not stack-overflow. Cycles are unusual in span attributes but
* span-event payloads occasionally get serialized error objects with cycles.
* Recursively redact a value of unknown shape (strings get rules 14; containers
* recurse; primitives pass through). The `seen` WeakSet guards against cycles,
* which serialized error objects in span-event payloads occasionally contain.
*/
export function redactValue(
value: unknown,
@@ -242,8 +151,7 @@ export function redactValue(
seen.add(value)
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
// Why: bundle collection re-redacts parsed NDJSON, where secrets can
// appear below attributes as nested HTTP headers or identity payloads.
// Why: re-redacting parsed NDJSON can surface secrets nested below attributes (headers, identity payloads).
if (shouldDropAttributeKey(k, mode)) {
continue
}
@@ -251,15 +159,11 @@ export function redactValue(
}
return out
}
// Functions / symbols coerce to a string label rather than carrying the
// value through. These do not show up in legitimate spans.
// Functions / symbols: coerce to a label; they don't appear in legitimate spans.
return `[unsupported:${typeof value}]`
}
/**
* Redact an attributes record: drop blocked keys, recursively redact values
* of remaining keys.
*/
/** Redact an attributes record: drop blocked keys, recursively redact the rest. */
export function redactAttributes(
attrs: Readonly<Record<string, unknown>>,
mode: RedactorMode = 'client'
@@ -302,17 +206,9 @@ export type RedactableSpan = {
}
/**
* Redact a complete span record. Returns a new record the input is not
* mutated, which keeps the redactor safe to run mid-pipeline (e.g. the sink
* holds a reference to the live span until end()) and idempotent.
*
* The exit `cause` string carries the formatted stack trace and is one of
* the most-likely places for a leaked secret (provider SDKs routinely echo
* the auth token back in the error message). Apply rules 14 there.
*
* Span event attribute keys are redacted with the same blocklist as span
* attributes; an `authorization` event-attribute is just as leaky as an
* `authorization` span-attribute.
* Redact a complete span record into a fresh record (input not mutated) so the
* redactor stays safe to run mid-pipeline and idempotent. The exit `cause` holds
* the stack trace a likely secret-leak site so rules 14 run there too.
*/
export function redactSpan(span: RedactableSpan, mode: RedactorMode = 'client'): RedactableSpan {
const redactedAttrs = redactAttributes(span.attributes, mode)
@@ -339,8 +235,7 @@ export function redactSpan(span: RedactableSpan, mode: RedactorMode = 'client'):
}
}
// ── Test-only introspection (kept here so tests can verify the rule set
// without re-deriving it from external assertions). ─────────────────────────
// Test-only introspection: lets tests verify the rule set without re-deriving it.
export const _internalsForTests = {
PROVIDER_PATTERNS,
+32 -88
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this persistence suite keeps defaulting,
migration, mutation, and flush behavior in one file so schema changes are
reviewed against the full storage contract instead of being scattered. */
/* eslint-disable max-lines -- Why: one file keeps the full storage contract (defaults, migration, mutation, flush) reviewable together. */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
writeFileSync,
@@ -47,9 +45,7 @@ import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-
// Shared mutable state so the electron mock can reference a per-test directory
const testState = { dir: '' }
// Stub the ~/.ssh/config parser so the SSH-import integration test below drives
// the real Store (real normalizeSshTarget + disk round-trip) with deterministic
// config hosts instead of the operator's actual ~/.ssh/config.
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
@@ -717,8 +713,7 @@ describe('Store', () => {
it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => {
const store = await createStore()
// Why: a renderer-supplied non-bool must never persist as a truthy non-bool
// that would later read as "tray-minimize on".
// Why: a renderer-supplied non-bool must never persist as truthy and later read as "tray-minimize on".
store.updateSettings({ minimizeToTrayOnClose: 'true' as unknown as boolean })
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
store.updateSettings({ minimizeToTrayOnClose: 1 as unknown as boolean })
@@ -779,8 +774,7 @@ describe('Store', () => {
store.flush()
})
// Why: a profile opened on another OS must not rewrite the mac preference
// on its next flush; only the darwin consumers act on the value.
// Why: a profile opened on another OS must not rewrite the mac-only preference on flush.
await withPlatform('win32', async () => {
const store = await createStore()
store.updateSettings({ minimizeToTrayOnClose: true })
@@ -821,10 +815,7 @@ describe('Store', () => {
})
it('persists the existing-user onboarding backfill back to disk', async () => {
// Why: the upgrade-cohort backfill is derived at load; this asserts the
// backfilled onboarding+gate state round-trips through a write intact (the
// load-time scheduleSave that triggers it without a manual flush is wired
// via loadNeedsSave at the no-onboarding-block branch).
// Why: the upgrade-cohort backfill is derived at load; assert it round-trips through a write intact (load-time scheduleSave via loadNeedsSave, no manual flush).
writeDataFile({
schemaVersion: 1,
ui: {}
@@ -987,9 +978,7 @@ describe('Store', () => {
})
it('recovers a close timestamp when closed onboarding omits the closedAt key', async () => {
// Why: a persisted block missing `closedAt` entirely (vs an explicit null)
// must still stay closed via outcome recovery, guarding the
// `'closedAt' in raw` sanitizer branch separately from the null case.
// Why: a block missing `closedAt` entirely (vs explicit null) must still stay closed via outcome recovery, guarding the `'closedAt' in raw` branch.
writeDataFile({
onboarding: {
flowVersion: ONBOARDING_FLOW_VERSION,
@@ -1007,9 +996,7 @@ describe('Store', () => {
})
it('does not mutate gate fields for a consistent closed-onboarding existing user', async () => {
// Why: the gate must be idempotent. A user already persisted as
// closed+completed must round-trip unchanged — the backfill path must not
// fire and stomp the real closedAt with a fresh Date.now() each launch.
// Why: the gate must be idempotent — a closed+completed user round-trips unchanged, and the backfill must not stomp closedAt with a fresh Date.now().
const consistent = {
onboarding: {
flowVersion: ONBOARDING_FLOW_VERSION,
@@ -1242,8 +1229,7 @@ describe('Store', () => {
// ── 2. Load from existing valid file ─────────────────────────────────
it('reads repos from an existing data file', async () => {
// Why: hydration must serve the persisted username without spawning
// git/gh (issue #7225); resolution happens in background enrichment.
// Why: hydration serves the persisted username without spawning git/gh (issue #7225); resolution happens in background enrichment.
const repo = makeRepo({ gitUsername: 'testuser' })
writeDataFile({
schemaVersion: 1,
@@ -1384,8 +1370,7 @@ describe('Store', () => {
source: 'ssh-config'
})
// normalizeSshTarget must not strip `source` on update, and the new port
// must take effect — this is the persistence-layer guard for #4684 item #1.
// normalizeSshTarget must not strip `source` on update and the new port must take effect (persistence-layer guard for #4684 item #1).
const updated = store.updateSshTarget('ssh-src-1', { port: 2222, source: 'ssh-config' })
expect(updated?.port).toBe(2222)
expect(updated?.source).toBe('ssh-config')
@@ -1455,17 +1440,14 @@ describe('Store', () => {
expect(inserted[0]?.source).toBe('ssh-config')
expect(inserted[0]?.port).toBe(2200)
// Rotated port: the upsert must update the SAME target in place — and the
// real normalizeSshTarget must keep `source` and not falsely re-derive
// configHost into a permanently-dirty state.
// Rotated port: upsert updates the same target in place and normalizeSshTarget must keep `source` (no false re-derive into a permanently-dirty state).
sshConfigHostsToTargetsMock.mockReturnValue(candidate(2222, 'ssh-cfg-2'))
const changed = sshStore.importFromSshConfig()
expect(changed).toHaveLength(1)
expect(changed[0]?.port).toBe(2222)
expect(changed[0]?.source).toBe('ssh-config')
// A third identical sync is a no-op (dirty-check against the real persisted
// fields) — proving repeated auto-sync on every pane open writes nothing.
// A third identical sync is a no-op — repeated auto-sync on every pane open writes nothing.
expect(sshStore.importFromSshConfig()).toHaveLength(0)
// Exactly one cluster target on disk with the rotated port and source kept.
@@ -1909,8 +1891,7 @@ describe('Store', () => {
seed.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
seed.flush()
// Why: a fresh store never stamps the one-shot UI migration flags, so a
// second load+flush settles them — otherwise they, not the prune, mark dirty.
// Why: a second load+flush settles the one-shot UI migration flags first, so they (not the prune) don't mark the store dirty.
const warm = await createStore()
warm.flush()
@@ -1925,8 +1906,7 @@ describe('Store', () => {
createdAt: 1_000 + i,
scheduledFor: 1_000 + i
}
// A real legacy file predates runNumber; backfill must run BEFORE the prune
// so survivors keep their true ordinals instead of restarting at 1.
// Legacy files predate runNumber; backfill must run BEFORE the prune so survivors keep their true ordinals.
delete legacy.runNumber
return legacy
})
@@ -2003,9 +1983,7 @@ describe('Store', () => {
const runs = store.listAutomationRuns(automation.id)
expect(runs.some((run) => run.id === inFlight.id)).toBe(true)
// Final runs beyond the cap were still evicted. The store can briefly hold
// cap + 2: the last-created run finalizes after the prune at its creation,
// and the late completion lands without a prune of its own.
// Store can briefly hold cap+2: the last-created run finalizes after its creation-time prune, and the late completion lands without a prune of its own.
expect(runs.some((run) => run.id === firstCompletedId)).toBe(false)
expect(runs.length).toBeLessThanOrEqual(102)
})
@@ -3534,8 +3512,7 @@ describe('Store', () => {
displayName: 'remote-wt',
hostId: 'ssh:ssh-old'
})
// A hostId-less meta under the same id is treated as local and left behind
// (conservative: never deletes the wrong host's meta).
// A hostId-less meta is treated as local and left behind (never delete the wrong host's meta).
store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt' })
store.removeProjectForHost('shared', 'ssh:ssh-old')
@@ -3606,8 +3583,7 @@ describe('Store', () => {
it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => {
const store = await createStore()
// A meta on the old SSH host with no corresponding repo row — the re-point
// must still be saved, not left in memory only.
// A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only.
store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
@@ -3717,8 +3693,7 @@ describe('Store', () => {
setupMethod: 'provisioned'
})
// Meta-only re-adoption (no repo pinned to the old id) must still migrate
// the provisioned setup, or new worktrees would be born on a dead host id.
// Meta-only re-adoption must still migrate the provisioned setup, or new worktrees would be born on a dead host id.
store.reassignSshTargetId('ssh-old', 'ssh-new')
const setups = store.getProjectHostSetups()
@@ -4171,9 +4146,7 @@ describe('Store', () => {
store.addRepo(makeRepo({ issueSourcePreference: 'origin' }))
expect(store.getRepo('r1')!.issueSourcePreference).toBe('origin')
// Why: passing the key with value `undefined` must clear the preference.
// Plain `Object.assign` skips undefined values, so without the explicit
// delete branch in updateRepo, the persisted record would keep 'origin'.
// Why: `Object.assign` skips undefined, so updateRepo needs an explicit delete branch or the key with value undefined wouldn't clear.
store.updateRepo('r1', { issueSourcePreference: undefined })
expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined()
@@ -5226,8 +5199,7 @@ describe('Store', () => {
})
// ── Content-hash write skipping ────────────────────────────────────
// Why inode comparison: every real write is a tmp+rename, which allocates a
// new inode. An unchanged inode proves no write happened.
// Why inode comparison: every real write is a tmp+rename (new inode), so an unchanged inode proves no write happened.
it('skips the disk write when a mutation burst nets out to already-persisted state', async () => {
vi.useFakeTimers()
@@ -5270,8 +5242,7 @@ describe('Store', () => {
vi.useFakeTimers()
try {
const store = await createStore()
// Mutations every 500ms keep resetting the 1s trailing debounce; the
// 5s max-wait must force a write anyway.
// Mutations every 500ms reset the 1s debounce; the 5s max-wait must force a write anyway.
let width = 400
for (let i = 0; i < 11; i++) {
store.updateUI({ sidebarWidth: width++ })
@@ -5325,8 +5296,7 @@ describe('Store', () => {
store.persistPtyBinding(binding)
const inoBefore = statSync(dataFile()).ino
// The warm-restart re-bind storm: every restored terminal re-asserts an
// identical binding with a sync flush. Identical state must not rewrite.
// Warm-restart re-bind storm: an identical binding re-asserted with a sync flush must not rewrite.
store.persistPtyBinding(binding)
expect(statSync(dataFile()).ino).toBe(inoBefore)
@@ -6118,9 +6088,7 @@ describe('Store', () => {
})
it('uses recent as the default sort for a fresh install (no persisted sortBy)', async () => {
// Why: the legacy-recent→smart migration must gate on the *raw* persisted
// value, not the normalized default. Otherwise, changing the default sort
// to 'recent' would cause every fresh install to be mis-migrated to 'smart'.
// Why: the legacy-recent→smart migration must gate on the raw persisted value, not the normalized default, or fresh installs get mis-migrated to 'smart'.
writeDataFile({
schemaVersion: 1,
repos: [],
@@ -6299,11 +6267,7 @@ describe('Store', () => {
// ── terminalMacOptionAsAlt migration (issue #903) ───────────────────
it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => {
// Why: before the 'auto' mode shipped, 'true' was the global default.
// A persisted 'true' on an un-migrated install is indistinguishable
// from an explicit choice, so we flip to 'auto' and let detection pick
// the right value per keyboard layout. Non-US users stop losing their
// @ / € / [ ] characters.
// Why: legacy 'true' (old default) is indistinguishable from an explicit choice; flip un-migrated installs to 'auto' so non-US layouts keep @ / € / [ ].
writeDataFile({
schemaVersion: 1,
repos: [],
@@ -6439,8 +6403,7 @@ describe('Store', () => {
})
it('respects already-migrated settings with explicit "true"', async () => {
// After migration, if a user deliberately picks 'Both' in the UI,
// their choice is preserved on subsequent launches.
// A deliberate 'true' ('Both') choice post-migration is preserved on later launches.
writeDataFile({
schemaVersion: 1,
repos: [],
@@ -6456,21 +6419,15 @@ describe('Store', () => {
})
it('fresh install defaults terminalMacOptionAsAlt to "auto" and marks migrated', async () => {
// No data file at all: auto is the new default; migration is considered
// complete since there's nothing legacy to migrate.
// No data file: 'auto' is the new default and migration is complete (nothing legacy to migrate).
const store = await createStore()
expect(store.getSettings().terminalMacOptionAsAlt).toBe('auto')
// Fresh install: default is migrated=false (nothing loaded, so the
// migration code didn't run). On first persisted write, the flag stays
// false, which is fine — next load with legacy 'true' would still
// migrate correctly. Only loaded files flip the flag.
// Fresh install: migrated stays false (migration code never ran); a later load with legacy 'true' still migrates correctly.
expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(false)
})
it('missing terminalMacOptionAsAlt in persisted file defaults to "auto" and flags migrated', async () => {
// Existing file predates the setting entirely. Treat like upgrade from
// pre-Option-as-Alt Orca: land on 'auto' and mark migrated so we don't
// re-examine.
// Existing file predates the setting: land on 'auto' and mark migrated so we don't re-examine.
writeDataFile({
schemaVersion: 1,
repos: [],
@@ -9728,12 +9685,7 @@ describe('Store', () => {
})
// ── Telemetry cohort migration ─────────────────────────────────────
//
// The migration keys on `existsSync(dataFile)` rather than field-based
// inference because the `telemetry` field is new in this release: keying
// on its presence would misclassify every pre-telemetry install as fresh,
// silently flipping existing users to default-on and violating the social
// contract they installed Orca under.
// Why: keys on `existsSync(dataFile)`, not the new `telemetry` field, so pre-telemetry installs aren't misclassified as fresh and flipped default-on.
it('classifies a truly fresh install as new-user cohort (file absent → optedIn=true)', async () => {
// No data file written — truly fresh install of the telemetry release.
@@ -9771,10 +9723,7 @@ describe('Store', () => {
})
it('still classifies as existing-user cohort when the data file is corrupt', async () => {
// Load-bearing: `fileExistedOnLoad` stays true even when the parse
// throws, so the corrupt-file catch path must also apply the migration.
// Otherwise a user whose `orca-data.json` got corrupted would be
// silently opted in as if they were a fresh install.
// Load-bearing: the corrupt-file catch path keeps `fileExistedOnLoad` true so a corrupted install isn't silently opted in as fresh.
mkdirSync(testState.dir, { recursive: true })
writeFileSync(dataFile(), '{{{corrupt json', 'utf-8')
const store = await createStore()
@@ -9814,9 +9763,7 @@ describe('Store', () => {
})
describe('Store.migrateTabSwitchKeybindings', () => {
// Freezes the install cohort for the tab-switch convention swap on first load.
// Keys on `fileExistedOnLoad` (not field presence) so the verdict is stable
// even after a fresh install writes its own data file on later launches.
// Freezes the tab-switch cohort on first load, keying on `fileExistedOnLoad` (not field presence) so the verdict survives later launches.
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
@@ -9864,8 +9811,7 @@ describe('Store.migrateTabSwitchKeybindings', () => {
workspaceSession: {}
})
const store = await createStore()
// Existing file but cohort already resolved to 'done' — must not flip to
// 'pending' just because the data file happens to exist.
// Existing file, cohort already 'done' — must not flip to 'pending' just because the file exists.
expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done')
})
})
@@ -10194,9 +10140,7 @@ describe('Store native-chat tab viewMode persistence', () => {
rmSync(testState.dir, { recursive: true, force: true })
})
// Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab
// persisted before the field existed must default to 'terminal' — i.e. the
// field is absent on restore — so older sessions stay backward-compatible.
// Why: tabs persisted before viewMode existed default to 'terminal' so older sessions stay backward-compatible.
it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
+174 -536
View File
File diff suppressed because it is too large Load Diff
+62 -188
View File
@@ -1,7 +1,4 @@
/* eslint-disable max-lines -- Why: shell-ready startup command integration adds
~70 lines of scanner/promise wiring to spawn(). Splitting the method would scatter
tightly coupled PTY lifecycle logic (scan ready write exit cleanup) across
files without a cleaner ownership seam. */
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
import { basename, delimiter } from 'node:path'
import { win32 as pathWin32 } from 'node:path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
@@ -79,13 +76,9 @@ const PANE_IDENTITY_ENV_KEYS = [
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
// Why: only agent sessions get descendant tree-kill on shutdown. Agent CLIs
// spawn tool children in detached process groups the PTY's SIGHUP can never
// reach; plain user terminals keep classic semantics where deliberately
// detached (nohup-style) children survive the pane.
// Why: only agent sessions get descendant tree-kill (tool children run in detached groups SIGHUP can't reach); plain terminals skip it so nohup-detached children survive.
const ptyAgentSessionIds = new Set<string>()
// Why: descendant capture is async. Reattach and duplicate shutdown must wait
// for the original owner instead of returning a PTY that is about to die.
// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY.
type PtyShutdownOperation = {
promise: Promise<void>
immediate: boolean
@@ -99,16 +92,13 @@ type PendingLocalPtySpawn = {
const pendingLocalPtySpawns = new Map<string, Set<PendingLocalPtySpawn>>()
const ptyShellName = new Map<string, string>()
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
// Why: remembers the last positively-recognized agent foreground per PTY so a
// degraded/timed-out scan does not report the shell and look like an exit.
// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit.
const ptyLastRecognizedForeground = new Map<string, string>()
const ptyTerminalHandle = new Map<string, string>()
const ptyInitialCwd = new Map<string, string>()
// Why: reattach requests carry current settings, not the live process's launch
// context. Keep the first creator's WSL/native identity for the PTY incarnation.
// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity.
const ptyWslDistroById = new Map<string, string | null>()
// Why: node-pty callbacks must be disposed before environment teardown, but
// onExit separately owns physical process-exit proof during termination.
// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination.
const ptyDisposables = new Map<string, { dispose: () => void }[]>()
const ptyExitDisposables = new Map<string, { dispose: () => void }>()
const ptyCleanupCallbacks = new Map<string, () => void>()
@@ -216,8 +206,7 @@ function runPtyCleanup(id: string): void {
function getWslContextFromWorktreeId(
worktreeId: string | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
// Why: strip any synthetic `::workspace:<uuid>` folder-workspace suffix so WSL
// detection parses the real path, not a nonexistent identifier.
// Why: strip any synthetic `::workspace:<uuid>` suffix so WSL detection parses the real path, not a nonexistent identifier.
const worktreePath = worktreeId
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
: undefined
@@ -302,8 +291,7 @@ function armLocalPtyForceKill(
} 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 the logical shutdown continues waiting for physical exit.
// 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,
@@ -337,8 +325,7 @@ async function prepareLocalPtySpawn(id: string): Promise<void> {
pending.add(pendingSpawn)
pendingLocalPtySpawns.set(id, pending)
try {
// Why: shutdown must be able to cancel a stable session id while the
// asynchronous macOS capability probe runs and before node-pty exists.
// Why: shutdown must be able to cancel a stable session id during the async macOS capability probe, before node-pty exists.
await prepareMacosTccLoginShell()
if (pendingSpawn.canceled) {
throw new Error(`PTY spawn canceled: ${id}`)
@@ -399,16 +386,12 @@ function resolveForegroundFallbackProcess(
if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) {
return processName || null
}
// Why: Windows node-pty can expose only the terminal name (`xterm-256color`).
// The spawned shell is the best fallback for agent foreground enrichment.
// Why: Windows node-pty may expose only the terminal name (`xterm-256color`); the spawned shell is the best foreground fallback.
return shellName ?? processName ?? null
}
/** Basename of the spawned shell path, parsed for the *target* platform rather
* than the host's native separator. Why: on Windows the shell path uses `\`,
* but the POSIX `basename` (used when orchestrating from a non-Windows host or
* CI) would not split it and would store the whole `C:\...\powershell.exe`
* path as the shell name breaking the foreground/child-process comparison. */
/** Basename of the spawned shell path, parsed for the *target* platform.
* Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */
function getSpawnedShellName(shellPath: string): string {
return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath)
}
@@ -417,15 +400,7 @@ function getSpawnedShellName(shellPath: string): string {
* Disposes the native PTY handle while avoiding recycled-pid signals on POSIX.
*/
function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void {
// Why: node-pty's UnixTerminal.destroy() closes the master socket, which
// releases the ptmx fd to the OS — without this call the fd leaks until GC
// (see docs/fix-pty-fd-leak.md). destroy() also registers a close listener
// that fires `this.kill('SIGHUP')` AFTER the socket closes. On POSIX, by
// the time that listener runs the child may have exited and its pid been
// recycled to an unrelated user process — SIGHUP would land on a Chrome tab,
// editor, etc. Neutralize proc.kill on this instance before calling
// destroy() to defuse the hazard. On Windows, destroy() is itself kill();
// skip it only after we have already killed the ConPTY.
// 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
}
@@ -446,8 +421,7 @@ function requestPtyTermination(id: string, proc: pty.IPty): void {
runPtyCleanup(id)
disposePtyListeners(id)
const previousMode = ptyTerminationMode.get(id)
// Why: destructive cleanup neutralizes proc.kill below, so an outstanding
// graceful request must be escalated before its deadline can be disabled.
// 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')
@@ -467,16 +441,12 @@ function requestPtyTermination(id: string, proc: pty.IPty): void {
return
}
}
// Why: shutdown and orphan cleanup can race; node-pty's onExit listener and
// tracker must remain installed until the OS proves the child was reaped.
// Why: shutdown and orphan cleanup can race; keep onExit + tracker installed until the OS proves the child was reaped.
destroyPtyProcess(proc, { alreadyKilled: true })
}
export type LocalPtyProviderOptions = {
/** Why: `ctx.command` carries the renderer-chosen launch command (e.g. `pi`,
* `omp`, `claude`). Pi vs OMP must drive overlay source-dir selection in
* `buildPtyHostEnv` a cross-agent disk-presence fallback silently
* shadows the other agent's user extensions when both are installed. */
/** 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>,
@@ -488,13 +458,9 @@ export type LocalPtyProviderOptions = {
wslDistro?: string | null
}
) => Record<string, string>
/** Whether worktree-scoped shell history is enabled. When true (or absent)
* and a worktreeId is provided, HISTFILE is scoped per-worktree. */
/** 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 on a stock Windows machine, so reading it
* directly would ignore the user's shell preference. This callback lets the
* IPC layer inject the persisted setting without coupling the provider to the
* settings store. Returns undefined when no preference is set. */
/** 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
@@ -524,8 +490,7 @@ export class LocalPtyProvider implements IPtyProvider {
/**
* Spawns or reattaches a local PTY session for the renderer process.
*
* Windows shell launches can pre-deliver short startup commands in argv; this
* method preserves that state so the stdin fallback only runs when needed.
* Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed.
*/
async spawn(args: PtySpawnOptions): Promise<PtySpawnResult> {
const reattachId = normalizeLocalCallerSessionId(args.sessionId)
@@ -558,9 +523,7 @@ export class LocalPtyProvider implements IPtyProvider {
const defaultCwd = getDefaultCwd()
const cwd = args.cwd || defaultCwd
// Why: gate on the effective cwd (post default-cwd fallback), not the raw
// args.cwd — an omitted cwd resolves to a safe default and must not be
// rejected as if it were a root-like path.
// 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)
}
@@ -589,9 +552,7 @@ export class LocalPtyProvider implements IPtyProvider {
effectiveCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
} else if (process.platform === 'win32') {
// Why: shellOverride lets a single tab open in a different shell than the
// persisted default (e.g. "New WSL terminal" from the "+" submenu) without
// changing the user's setting. It takes priority over the setting.
// 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 ||
this.opts.getWindowsShell?.() ||
@@ -600,11 +561,7 @@ export class LocalPtyProvider implements IPtyProvider {
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily)
// Why: shell selection can arrive either as a canonical setting value
// ('powershell.exe') or as a concrete PowerShell executable path from a
// one-off override. Normalize both forms back to the PowerShell family so
// the shared resolver can still fall back to inbox powershell.exe when
// pwsh.exe was requested but is unavailable.
// Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe.
const powerShellImplementation = this.opts.getWindowsPowerShellImplementation?.()
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
@@ -631,12 +588,7 @@ export class LocalPtyProvider implements IPtyProvider {
}) ?? shellFamily)
: shellFamily
}
// Why: when the selected shell is a PowerShell family, resolve it to a
// real absolute executable and build a PowerShell -> cmd.exe fallback
// chain. Handing ConPTY a bare `pwsh.exe` lets Windows resolve it to the
// Store App Execution Alias stub, whose spawn fails with error code 5.
// The shared launch-args helper inside keeps both this path and the
// daemon path producing identical args (chcp 65001 / $PROFILE / wsl cwd).
// Why: bare `pwsh.exe` resolves to the Store App Execution Alias stub whose spawn fails (code 5); use an absolute exe + cmd.exe fallback.
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd,
@@ -679,22 +631,12 @@ export class LocalPtyProvider implements IPtyProvider {
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION (Neovim's termcap
// autodetection, bat/delta paging hints). Sourced from ORCA_APP_VERSION
// which main/index.ts seeds from app.getVersion() at startup; the
// fallback keeps tests and non-Electron runs working.
// 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: opt tools (Claude Code, ls --hyperlink, etc.) into emitting OSC 8
// hyperlinks. The `supports-hyperlinks` npm package gates on a hard-coded
// TERM_PROGRAM allowlist (iTerm.app / WezTerm / vscode) and returns false
// for TERM_PROGRAM=Orca, so callers drop OSC 8 output entirely and emit
// bare text instead. xterm.js in Orca parses OSC 8 and the pane's
// linkHandler routes clicks, so forcing the advertisement is safe and
// restores clickable refs like `owner/repo#123` / `PR#123`.
// 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 while developing. Pane
// identity belongs to the child PTY, not the parent shell that spawned app.
// Why: Orca can be launched from an Orca terminal; pane identity belongs to the child PTY, not the parent shell.
removeUnspecifiedPaneIdentityEnv(spawnEnv, args.env)
removeAppImageRuntimeEnv(spawnEnv)
removeInheritedNoColor(spawnEnv)
@@ -707,16 +649,11 @@ export class LocalPtyProvider implements IPtyProvider {
spawnEnv.LANG ??= 'en_US.UTF-8'
// Why: On Windows, LANG alone does not control the console code page.
// Programs like Python and Node.js check their own encoding env vars
// independently. PYTHONUTF8=1 makes Python use UTF-8 for stdio regardless
// of the Windows console code page, preventing garbled CJK output from
// Python scripts run inside the terminal.
// 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(shellPath)) {
// Why: Git for Windows login startup files otherwise cd to $HOME,
// ignoring node-pty's cwd for repo-scoped terminals.
// Why: Git for Windows login files otherwise cd to $HOME, ignoring node-pty's cwd for repo-scoped terminals.
spawnEnv.CHERE_INVOKING ??= '1'
}
}
@@ -733,9 +670,7 @@ export class LocalPtyProvider implements IPtyProvider {
wslDistro: launchWslDistro
})
: spawnEnv
// Why: app-level env hooks can reintroduce vars that special launch modes
// explicitly scrubbed. Apply deletions last so shims like Claude Agent
// Teams keep their PATH and terminal-detection contract.
// 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 args.envToDelete ?? []) {
delete finalEnv[key]
}
@@ -766,27 +701,22 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
} else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) {
// Why: Orca's selected Codex runtime home is host-local. WSL Codex
// must use its Linux-side ~/.codex instead of a Windows path.
// Why: Orca's Codex home is host-local; WSL Codex must use its Linux-side ~/.codex, not a Windows path.
delete finalEnv.CODEX_HOME
delete finalEnv.ORCA_CODEX_HOME
} else if (finalEnv.CODEX_HOME) {
addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
}
if (finalEnv.CLAUDE_CONFIG_DIR) {
// Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR
// through Windows wsl.exe; non-default env vars need WSLENV import.
// Why: managed WSL Claude passes a Linux CLAUDE_CONFIG_DIR through wsl.exe; non-default vars need WSLENV import.
addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR'])
}
if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
// Why: the startup wrapper expands this only inside WSL; wsl.exe
// otherwise drops custom Windows environment variables.
// Why: wsl.exe drops custom Windows env vars; the startup wrapper needs this imported inside WSL.
addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
// CODEX_HOME from it after user profiles run.
// Why: WSL Codex homes are Linux paths Windows can't use; also drop ORCA_CODEX_HOME (shell-ready restores CODEX_HOME from it).
delete finalEnv.CODEX_HOME
delete finalEnv.ORCA_CODEX_HOME
}
@@ -800,8 +730,7 @@ export class LocalPtyProvider implements IPtyProvider {
addWslEnvKeys(finalEnv, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
if (!wslInfo && process.platform !== 'win32') {
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
// Why: OpenCode/Codex PATH restoration and OMP's status wrapper need shell-ready code after user startup files run.
const needsNoMarkerWrapper =
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
@@ -816,8 +745,7 @@ export class LocalPtyProvider implements IPtyProvider {
command: args.command,
startupCommandDelivery: args.startupCommandDelivery
})
// Why: payload-bearing Codex startup text can be dropped by rc-file noise;
// plain Codex stays markerless to preserve the startup-speed path.
// Why: payload-bearing Codex startup can be lost to rc-file noise; plain Codex stays markerless for startup speed.
getFallbackShellReadyConfig = (shell) =>
shouldWaitForShellReady
? getShellReadyLaunchConfig(shell)
@@ -842,13 +770,10 @@ export class LocalPtyProvider implements IPtyProvider {
}
promoteAgentTeamsShimPath(finalEnv, args.env?.PATH)
// ── Worktree-scoped shell history (§7–§10 of terminal-history-scope-design) ──
// Why: without this, all worktree terminals share a single global HISTFILE
// so ArrowUp in worktree B surfaces commands from worktree A.
// Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10).
const worktreeId = args.worktreeId
const historyEnabled = worktreeId && (this.opts.isHistoryEnabled?.() ?? true)
// Resolve the effective shell kind for history injection. For WSL, the
// outer executable is wsl.exe but the inner login shell is bash.
// Effective shell for history injection: WSL's outer exe is wsl.exe but the inner login shell is bash.
const isWslTerminal =
Boolean(wslInfo || worktreeWslContext || preferredWslContext) ||
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
@@ -872,17 +797,14 @@ export class LocalPtyProvider implements IPtyProvider {
termName: finalEnv.TERM,
ptySpawn: pty.spawn,
getShellReadyConfig: getFallbackShellReadyConfig,
// Why: if zsh failed and bash took over, HISTFILE still points to
// zsh_history. Update it *before* spawn so the child inherits the
// correct filename (see design doc §8).
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
onBeforeFallbackSpawn: historyResult?.histFile
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
: undefined,
windowsFallbackAttempts
})
shellPath = spawnResult.shellPath
// Why: a Windows fallback (e.g. cmd.exe) embeds its own startup command in
// argv, so honor the winning shell's delivery flag to avoid a double write.
// 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) {
startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs
}
@@ -904,9 +826,7 @@ export class LocalPtyProvider implements IPtyProvider {
if (spawnedWslDistro !== undefined) {
ptyWslDistroById.set(id, spawnedWslDistro)
}
// Why both signals: launchAgent is the caller's explicit intent and
// survives command rewriting (e.g. auth env prefixes); recognition covers
// callers that pass a bare agent command line without the flag.
// Why both: launchAgent is explicit intent that survives command rewrites; recognition catches bare agent command lines.
if (args.launchAgent || startupAgentRecognition) {
ptyAgentSessionIds.add(id)
}
@@ -1025,15 +945,7 @@ export class LocalPtyProvider implements IPtyProvider {
const onExitDisposable = proc.onExit(({ exitCode }) => {
const wasTerminationRequested = ptyTerminationMode.has(id)
ptyPhysicalExits.get(id)?.markExited()
// Why: neutralize proc.kill the instant the child is reaped, before any
// other work in this callback. node-pty's UnixTerminal installs a
// `_socket.once('close', () => this.kill('SIGHUP'))` handler at destroy
// time, but the master socket can also emit 'close' on natural exit
// between this onExit callback starting and destroyPtyProcess() running
// below. If 'close' wins, SIGHUP is dispatched to proc.pid — which on
// POSIX has already been reaped and may have been recycled to an
// unrelated process. Synchronous neutralization here closes that window.
// Windows is exempt: WindowsTerminal.destroy is implemented via kill().
// 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 = () => {}
}
@@ -1045,9 +957,7 @@ export class LocalPtyProvider implements IPtyProvider {
clearPtyState(id)
startupIngress.drainAndClose()
startupIngressByPty.delete(id)
// Why: release the master ptmx fd on the natural-exit path — without
// this, a shell that exits cleanly (the common case) never releases its
// fd until the next GC. See docs/fix-pty-fd-leak.md.
// 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 })
this.opts.onExit?.(id, exitCode)
for (const cb of exitListeners) {
@@ -1060,9 +970,7 @@ export class LocalPtyProvider implements IPtyProvider {
ptyDisposables.set(id, disposables)
if (args.command && !startupCommandDeliveredInShellArgs) {
// Why: only Orca-wrapped POSIX bash/zsh have bracketed-paste mode armed
// (bash via `bind`, zsh on by default), so multiline startup prompts can
// be pasted literally there; other shells keep the raw submit path.
// Why: only POSIX bash/zsh have bracketed-paste armed so multiline startup prompts paste literally; others use raw submit.
const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase()
const bracketedPasteSafe =
process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh')
@@ -1077,9 +985,7 @@ export class LocalPtyProvider implements IPtyProvider {
)
}
// Why: publish the OS pid so ipc/pty can register the PTY with the memory
// collector without reaching back into the provider. `proc.pid` may be
// briefly 0/undefined if node-pty hasn't observed the forked child yet.
// 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 {
@@ -1101,10 +1007,7 @@ export class LocalPtyProvider implements IPtyProvider {
ptyProcesses.get(id)?.resize(cols, rows)
}
// Why: node-pty pause() stops reading the pty master fd, so the kernel
// buffer fills and a flooding child blocks on write — true producer
// backpressure. Best-effort: a PTY torn down mid-call must never throw
// into the flow-control path.
// Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure.
pauseProducer(id: string): void {
try {
ptyProcesses.get(id)?.pause()
@@ -1121,11 +1024,7 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
// Why: node-pty caches the last winsize it applied on the IPty handle, so its
// cols/rows are the authoritative applied size (node-pty clamps invalid dims
// and a resize on a dead handle is a no-op, neither of which the requested
// size in ptySizes would reflect). The renderer's resume drift-check compares
// against this to re-assert a resize the PTY never actually took.
// Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
const proc = ptyProcesses.get(id)
if (!proc || proc.cols <= 0 || proc.rows <= 0) {
@@ -1174,19 +1073,16 @@ export class LocalPtyProvider implements IPtyProvider {
operation: PtyShutdownOperation
): Promise<void> {
const physicalExit = ptyPhysicalExits.get(id)
// Why: the snapshot must precede any signal — once the shell dies,
// surviving descendants reparent to pid 1 and a ppid walk can't find them.
// Why: snapshot before signaling — once the shell dies, descendants reparent to pid 1 and a ppid walk can't find them.
const descendants = ptyAgentSessionIds.has(id)
? await captureDescendantSnapshot(proc.pid)
: null
// Why: a natural exit can race the snapshot. Never signal descendants or
// a root PID after this exact PTY has lost ownership.
// Why: a natural exit can race the snapshot — never signal descendants or the root PID after this PTY loses ownership.
if (ptyProcesses.get(id) === proc) {
if (descendants) {
terminateDescendantSnapshot(descendants)
}
// Cancel startup delivery now, but preserve the exit listener and all
// ownership maps until node-pty reports the physical process exit.
// Cancel startup delivery now, but keep the exit listener and ownership maps until node-pty reports physical exit.
runPtyCleanup(id)
operation.rootSignalled = true
this.requestTrackedPtyShutdown(id, proc, operation.immediate)
@@ -1196,8 +1092,7 @@ export class LocalPtyProvider implements IPtyProvider {
private requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void {
const previousMode = ptyTerminationMode.get(id)
// Why: ConPTY has no graceful signal; its first bare node-pty kill closes
// the pseudoconsole and must be treated as the final force request.
// 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)
@@ -1233,30 +1128,19 @@ export class LocalPtyProvider implements IPtyProvider {
async getCwd(id: string): Promise<string> {
const proc = ptyProcesses.get(id)
// Why: return '' (not throw) on unknown id — the renderer treats empty
// as "no result, try the next fallback layer". Throwing would surface a
// noisy rejection for a non-exceptional case (PTY just exited, pane
// still has its old 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: resolveProcessCwd returns '' when it can't resolve — let that
// empty surface so the renderer's fallback chain decides what to do.
// Handing back a fabricated initialCwd here would lie to the renderer
// and short-circuit that chain.
// Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it.
return resolveProcessCwd(proc.pid)
}
async getInitialCwd(_id: string): Promise<string> {
return ''
}
async clearBuffer(id: string): Promise<void> {
// Why: xterm.js clear() only resets the renderer. ConPTY keeps its own
// screen buffer, so without this its stale cursor row makes the next
// prompt repaint land below a blank gap. No-op on POSIX.
//
// Unlike the daemon session, no PSReadLine form-feed nudge here: it is
// only safe at an empty prompt, and without a headless emulator this
// provider cannot tell whether input is pending.
// 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()
@@ -1300,8 +1184,7 @@ export class LocalPtyProvider implements IPtyProvider {
)
const cachedAgent = ptyLastRecognizedForeground.get(id) ?? null
let consoleMembershipUnavailable = false
// Why: exact console membership can preserve a live cached agent without
// trusting the whole-table scan that becomes incomplete under Windows load.
// Why: console membership preserves a live cached agent without the whole-table scan (incomplete under Windows load).
if (
process.platform === 'win32' &&
canConfirmAgentFromConsolePresence(cachedAgent, fallbackProcess)
@@ -1327,21 +1210,16 @@ export class LocalPtyProvider implements IPtyProvider {
contextPaths: ptyAgentForegroundContextPaths.get(id)
}
)
// Why: the scan can outlive PTY teardown or id reuse; stale results must
// not resurrect cache state for a process that no longer owns this id.
// 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/timed-out scan must not report the shell as the
// foreground — the completion coordinator reads that as an exit and fires
// a false "agent done" while the agent is still working. Prefer the last
// recognized agent across a transient failure (e.g. a Windows CIM timeout).
// Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead.
const lastRecognizedAgent = ptyLastRecognizedForeground.get(id) ?? null
const resolvedAgent = resolution.processName
? recognizeAgentProcessFromCommandLine(resolution.processName)
: null
// Why: an incomplete global snapshot plus an unavailable console probe is
// not exit proof; only verified shell-only membership may clear the cache.
// Why: incomplete snapshot + unavailable console probe isn't exit proof; only shell-only membership may clear the cache.
const stable = resolveStableForegroundProcess(
consoleMembershipUnavailable && resolvedAgent === null
? { ...resolution, available: false }
@@ -1358,8 +1236,7 @@ export class LocalPtyProvider implements IPtyProvider {
if (ptyProcesses.get(id) !== proc) {
return null
}
// Why: an inspection error is itself a degraded read; fall back to the
// last recognized agent rather than null (which also reads as an exit).
// Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit).
return ptyLastRecognizedForeground.get(id) ?? null
}
}
@@ -1384,8 +1261,7 @@ export class LocalPtyProvider implements IPtyProvider {
: {})
}
)
// Why: a fresh scan can outlive this PTY id; never publish identity from
// an exited process or a replacement session that reused the same id.
// 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
}
@@ -1490,11 +1366,9 @@ export class LocalPtyProvider implements IPtyProvider {
/* Process may already be dead. */
}
}
// Why: app quit cannot retain NAPI callbacks into FreeEnvironment; the
// process exit itself is the final physical handle boundary here.
// 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 callback as the final owner;
// overlapping shutdown waiters must join that same terminal boundary.
// Why: app quit replaces node-pty's onExit as final owner; overlapping shutdown waiters must join this boundary.
ptyPhysicalExits.get(id)?.markExited()
clearPtyState(id)
}
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: shell-ready wrapper coverage keeps zsh,
bash, marker scanning, and env restoration cases in one suite so the
generated wrapper contract is reviewed as a unit. */
/* eslint-disable max-lines -- Why: keeps the whole generated-wrapper contract (zsh, bash, marker scanning, env restore) in one suite. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
@@ -14,11 +12,7 @@ import {
writeStartupCommandWhenShellReady
} from './local-pty-shell-ready'
// Why: the wrapper root is resolved from ORCA_USER_DATA_PATH (main
// canonicalizes it at startup; the daemon fork sets it explicitly). This
// module must not import electron because it is bundled into the plain-node
// daemon-entry fork, so tests point the root through the env var rather than
// mocking electron's app.
// Why: can't import electron (bundled into the plain-node daemon-entry fork), so tests set the wrapper root via ORCA_USER_DATA_PATH instead of mocking app.
function setTestUserDataPath(path: string): void {
process.env.ORCA_USER_DATA_PATH = path
}
@@ -170,11 +164,7 @@ describe('writeStartupCommandWhenShellReady', () => {
expect(proc._writes).toEqual(['codex\n'])
})
// Why: regression for the multiline agent-prompt bug. A startup command with
// embedded newlines must be wrapped in bracketed paste (ESC[200~ … ESC[201~)
// followed by a single submit byte, so bash readline / zsh zle insert the
// whole prompt literally instead of reading each LF as Enter and mangling it
// into PS2 continuation.
// Why: multiline startup commands must be bracketed-paste wrapped (ESC[200~ … ESC[201~) so shells insert them literally instead of treating each LF as Enter.
it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
@@ -213,8 +203,7 @@ describe('writeStartupCommandWhenShellReady', () => {
const proc = createMockProc()
const ready = Promise.resolve()
const command = 'echo one\necho two'
// Default options: bracketedPasteSafe is false, so the raw path is kept to
// avoid echoing the ESC[200~ markers on shells without bracketed paste.
// Why: bracketedPasteSafe defaults false, so keep the raw path to avoid echoing ESC[200~ on shells without bracketed paste.
writeStartupCommandWhenShellReady(ready, proc, command, () => {})
await ready
@@ -284,9 +273,7 @@ describe('scanForShellReady', () => {
})
describe('shell-ready wrapper root resolution', () => {
// Why: regression guard — the daemon-entry fork runs as plain Node and cannot
// import electron, so the wrapper root must resolve from ORCA_USER_DATA_PATH
// (set by main at startup and by the daemon fork) rather than app.getPath.
// Why: daemon-entry fork is plain Node (no electron), so the wrapper root resolves from ORCA_USER_DATA_PATH, not app.getPath.
it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-userdata-env-'))
try {
@@ -377,9 +364,7 @@ describePosix('local PTY shell-ready launch config', () => {
})
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
// Why: mirrors the daemon path — guards the same zsh recursion loop for
// PTYs spawned by the renderer/local provider when Orca is launched from
// inside an Orca terminal (e.g. `pn dev`).
// Why: mirrors the daemon path — guards the same zsh recursion loop for renderer/local PTYs spawned inside an Orca terminal.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
@@ -493,12 +478,9 @@ describePosix('local PTY shell-ready launch config', () => {
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
// Why: add-zle-hook-widget aborts its hook chain when an earlier hook
// exits non-zero (e.g. oh-my-zsh vi-mode's raw zle-line-init), so the
// marker must not be registered through it.
// Why: add-zle-hook-widget aborts its chain on a non-zero earlier hook (e.g. oh-my-zsh vi-mode); don't register the marker through it.
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
// Why: re-source guard — skip re-capturing when we are already the bound
// widget so the prior widget chain survives a second source.
// Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source.
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
})
@@ -542,10 +524,7 @@ describePosix('local PTY shell-ready launch config', () => {
expect(bashRc).toContain(ompWrapperLine)
})
// Why: regression guard for issue #2422. Without OSC 133 C/D markers in the
// bash rc, Linux/bash sessions kept the worktree spinner "working" for up to
// 30 min after the agent CLI exited, because the renderer's command
// lifecycle never observed a 'D' marker to drop the stale agent row.
// Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited.
it('emits OSC 133 C/D markers in the bash wrapper so agent exit cleanup fires', async () => {
const { getBashShellReadyRcfileContent, getZshShellReadyRcfileContent } =
await importFreshLocalPtyShellReady()
@@ -553,8 +532,7 @@ describePosix('local PTY shell-ready launch config', () => {
const bashRc = getBashShellReadyRcfileContent()
const zshRc = getZshShellReadyRcfileContent()
// The exact escape sequences the renderer's terminal-command-lifecycle
// parses (133;D for command-finished, 133;C for command-start).
// The exact escape sequences terminal-command-lifecycle parses (133;D = finished, 133;C = start).
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
expect(bashRc).toContain('printf "\\033]133;C\\007"')
expect(bashRc).toContain(
@@ -563,8 +541,7 @@ describePosix('local PTY shell-ready launch config', () => {
expect(bashRc.indexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
bashRc.indexOf('if [[ "${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then')
)
// Sanity: zsh wrapper still emits the same markers — both branches must
// stay in sync.
// Sanity: zsh wrapper emits the same markers — both branches must stay in sync.
expect(zshRc).toContain('printf "\\033]133;D;%s\\007"')
expect(zshRc).toContain('printf "\\033]133;C\\007"')
})
@@ -690,9 +667,7 @@ describePosix('local PTY shell-ready launch config', () => {
})
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
// Why: PR #1737 sourced .zshenv inside a wrapper function, which broke
// common patterns like "typeset -U path". The fix must keep .zshenv at
// zsh top level while still capturing the ZDOTDIR it resolved.
// Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at top level.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
@@ -710,8 +685,7 @@ describePosix('local PTY shell-ready launch config', () => {
})
it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => {
// Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper
// should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME.
// Why: if user .zshenv returns early or doesn't set ZDOTDIR, fall back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
@@ -726,29 +700,22 @@ describePosix('local PTY shell-ready launch config', () => {
})
it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => {
// Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c,
// so the generation-time path baked into .zshenv does not exist there.
// Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c, so the baked generation-time path is absent.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
// Why: derive the wrapper dir from %x (zsh's internal script name), not the
// env-imported $ZDOTDIR — zsh corrupts environment values whose UTF-8 bytes
// fall in its 0x84-0x9D token range (non-ASCII Windows usernames), which
// would fail the self-check and fall back to the unusable baked literal.
// Why: derive wrapper dir from %x, not env $ZDOTDIR — zsh corrupts non-ASCII usernames in its 0x84-0x9D token range.
expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"')
// Keep $ZDOTDIR only as a fallback when %x expansion yields nothing. The
// final restore below re-validates with -f before trusting the value, so no
// stat is needed here.
// Keep $ZDOTDIR only as a fallback when %x yields nothing; the final restore re-validates with -f, so no stat here.
expect(zshenv).toContain(
'if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then\n' +
' _orca_wrapper_zdotdir_self="${ZDOTDIR:-}"\n' +
'fi'
)
// The runtime path is only trusted when it still holds a wrapper .zshenv;
// otherwise the generation-time literal remains as the fallback.
// Trust the runtime path only when it still holds a wrapper .zshenv; else fall back to the generation-time literal.
expect(zshenv).toContain(
'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' +
' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' +
@@ -763,9 +730,7 @@ describePosix('local PTY shell-ready launch config', () => {
})
})
// Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level
// zsh semantics. These tests spawn real zsh subprocesses, so they're gated on
// zsh availability and skipped on platforms where zsh is not found.
// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability).
describePosix('live zsh subprocess tests', () => {
const hasZsh = (() => {
const result = spawnSync('which', ['zsh'], { encoding: 'utf8' })
@@ -790,9 +755,7 @@ describePosix('live zsh subprocess tests', () => {
})
it('preserves typeset -U path scoping when user .zshrc uses it', async () => {
// Why: this was the breakage pattern in PR #1737. The function-wrapper
// approach made "typeset -U path" function-scoped. User rcfiles must
// still be sourced at the wrapper's top level, preserving scoping.
// Why: PR #1737's function-wrapper made "typeset -U path" function-scoped; user rcfiles must source at top level.
// Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH
const xdgZshDir = join(testHome, '.config', 'zsh')
@@ -813,11 +776,7 @@ path=(/custom/bin $path)
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
// Spawn interactive zsh with the wrapper and verify:
// 1. Wrapper discovered XDG ZDOTDIR from .zshenv
// 2. User's .zshrc was sourced from discovered ZDOTDIR
// 3. typeset -U path modification persisted (proving top-level scoping)
// Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime
// Verify the wrapper discovered XDG ZDOTDIR, sourced user .zshrc, and kept typeset -U path (proves top-level scoping).
const cleanEnv: Record<string, string | undefined> = {
...process.env,
HOME: testHome,
@@ -825,8 +784,7 @@ path=(/custom/bin $path)
}
delete cleanEnv.ZDOTDIR
delete cleanEnv.ORCA_ORIG_ZDOTDIR
// Why: attribution shims are intentionally restored after user rcfiles;
// this test isolates zsh top-level path scoping, not attribution ordering.
// Why: this test isolates zsh top-level path scoping, not attribution shim ordering.
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir
@@ -850,11 +808,7 @@ path=(/custom/bin $path)
})
it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => {
// Why: issue #8003 — on Windows the wrappers are generated under the
// native userData path but WSL sources them via /mnt/c, where the baked
// generation-time path does not exist. Renaming the userData dir after
// generation reproduces that split: runtime ZDOTDIR resolves, the baked
// literal does not.
// Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c where the baked path is absent; renaming userData reproduces that split.
writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n')
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
@@ -874,10 +828,7 @@ path=(/custom/bin $path)
delete cleanEnv.USER_ZSHRC_LOADED
cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh')
// Production WSL launches a login shell (`exec zsh -l`); also cover the
// non-login flow used by local panes so both restore paths stay pinned.
// Login must still load user .zshrc (via wrapper .zshrc after .zprofile)
// and leave final ZDOTDIR at the user home after .zlogin restore.
// Cover both the WSL login shell (`exec zsh -l`) and the non-login local-pane flow so both restore paths stay pinned.
for (const args of [['-i'], ['-l', '-i']] as const) {
const result = spawnSync(
'zsh',
@@ -895,8 +846,7 @@ path=(/custom/bin $path)
expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0)
expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes')
expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`)
// Why: `as const` makes .includes('-l') reject the union of tuple
// element types; check the login flag by position instead.
// Why: `as const` makes .includes('-l') reject the tuple union type; check by position instead.
expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no')
}
} finally {
@@ -905,19 +855,13 @@ path=(/custom/bin $path)
})
it('loads user .zshrc when the wrapper dir contains a non-ASCII (token-range) path', async () => {
// Why: issue #8003 second trigger — a non-ASCII Windows username (e.g. a
// Korean login) puts UTF-8 bytes in zsh's 0x84-0x9D token range into the
// wrapper path. zsh corrupts the env-imported $ZDOTDIR while processing
// startup files, so deriving the wrapper dir from $ZDOTDIR fails the
// self-check and falls back to the unusable baked literal, leaving the
// user's .zshrc unloaded. Deriving from %x sidesteps the corruption.
// Why: issue #8003 — non-ASCII usernames put UTF-8 bytes in zsh's 0x84-0x9D token range, corrupting env-imported $ZDOTDIR; derive from %x instead.
writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n')
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
// Move the generated wrappers under a non-ASCII runtime root so the baked
// literal is unusable and the runtime $ZDOTDIR gets corrupted on import.
// Move wrappers under a non-ASCII root so the baked literal is unusable and runtime $ZDOTDIR corrupts on import.
const nonAsciiUserData = join(dirname(userDataPath), '홍길동-wsl-view')
renameSync(userDataPath, nonAsciiUserData)
try {
@@ -951,8 +895,7 @@ path=(/custom/bin $path)
})
it('preserves top-level .zshenv path and function side effects', async () => {
// Why: .zshenv is the normal place for always-on zsh env/path setup.
// Dropping those side effects regresses non-Orca zsh startup semantics.
// Why: .zshenv is the normal place for always-on env/path setup; dropping side effects regresses zsh startup.
const xdgZshDir = join(testHome, '.config', 'zsh')
mkdirSync(xdgZshDir, { recursive: true })
writeFileSync(
@@ -997,8 +940,7 @@ export ZDOTDIR="$HOME/.config/zsh"
})
it('sources user startup files with their own ZDOTDIR in scope', async () => {
// Why: plugin managers such as Antidote resolve files from $ZDOTDIR
// while .zprofile/.zshrc/.zlogin are sourced.
// Why: plugin managers such as Antidote resolve files from $ZDOTDIR while startup files are sourced.
const xdgZshDir = join(testHome, '.config', 'zsh')
const zdotdirLog = join(testHome, 'zdotdir.log')
mkdirSync(xdgZshDir, { recursive: true })
@@ -1047,8 +989,7 @@ export ZDOTDIR="$HOME/.config/zsh"
})
it('survives early return in user .zshenv without crashing', async () => {
// Why: common pattern to skip non-interactive sourcing. A direct source
// at zsh top level must keep the wrapper running, matching normal zsh.
// Why: early return is a common non-interactive-skip pattern; top-level sourcing must keep the wrapper running.
writeFileSync(
join(testHome, '.zshenv'),
`[[ -o interactive ]] || return 0
@@ -1081,8 +1022,7 @@ export ZDOTDIR="$HOME/.config/zsh"
})
it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => {
// Why: vanilla zsh users don't set ZDOTDIR. The fallback chain should
// land on HOME after preserving the rest of .zshenv behavior.
// Why: vanilla zsh users don't set ZDOTDIR, so the fallback chain must land on HOME.
writeFileSync(
join(testHome, '.zshenv'),
`# Vanilla zsh config, no ZDOTDIR
@@ -1333,8 +1273,7 @@ export MY_VAR=foo
})
it('does not source /.zshenv when HOME is empty', async () => {
// Create /.zshenv to verify it's NOT sourced
// (can't actually create in test but we verify the wrapper logic)
// Can't create /.zshenv in the test, so verify the wrapper logic guards against it.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
@@ -1848,9 +1787,7 @@ export MY_VAR=foo
})
it('handles unset HOME gracefully', async () => {
// When HOME is unset at spawn, zsh initializes it from /etc/passwd before
// running the wrapper, so the wrapper can discover ZDOTDIR normally.
// This verifies the wrapper doesn't crash when HOME is initially unset.
// Why: zsh initializes HOME from /etc/passwd when unset at spawn, so the wrapper can still discover ZDOTDIR.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
+12 -61
View File
@@ -1,14 +1,9 @@
/* eslint-disable max-lines -- Why: this module owns both shell wrapper file
generation and the matching startup-command readiness scanner; splitting
them would make the wrapper/marker contract harder to audit. */
/* eslint-disable max-lines -- Why: owns both wrapper-file generation and the matching readiness scanner; splitting would fragment the wrapper/marker contract. */
/**
* Shell-ready startup command support for local PTYs.
*
* Why: when Orca needs to inject a startup command (e.g. issue command runner),
* it must wait until the shell has fully initialized before writing. This module
* provides shell wrapper rcfiles that emit an OSC 777 marker after startup,
* and a data scanner that detects that marker so the command can be written at
* the right time.
* Why: startup commands must wait until the shell has fully initialized. Provides shell wrapper
* rcfiles that emit an OSC 777 marker after startup, plus a scanner that detects it.
*/
import { tmpdir } from 'node:os'
import { basename, win32 as pathWin32 } from 'node:path'
@@ -49,10 +44,7 @@ export type ShellReadySignal = {
// ── Shell wrapper files ─────────────────────────────────────────────
function getShellReadyWrapperRoot(): string {
// Why: bundled into daemon-entry.js (a plain-node fork with no electron
// require), so this must not import electron. Main canonicalizes
// ORCA_USER_DATA_PATH to its own userData at startup (configureOrcaUserDataPathEnv)
// and the daemon fork sets it explicitly, so the env value matches this root.
// Why: bundled into the daemon fork (no electron), so read ORCA_USER_DATA_PATH rather than electron's userData; main and the fork both set it to the same path.
const userDataPath = process.env.ORCA_USER_DATA_PATH ?? tmpdir()
return `${userDataPath}/shell-ready`
}
@@ -71,27 +63,12 @@ function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean {
return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path))
}
// Why: if our own process inherited ZDOTDIR from a parent shell that was
// itself an Orca PTY (e.g. the user launched `pn dev` from a terminal inside
// a running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir.
// Propagating it as the new PTY's ORCA_ORIG_ZDOTDIR makes the wrapper's
// `source "$ORCA_ORIG_ZDOTDIR/.zshenv"` line source itself recursively —
// zsh gives "job table full or recursion limit exceeded" and the shell
// never reaches a usable prompt.
//
// Any path component ending in `/shell-ready/zsh` is an Orca wrapper dir
// (regardless of whether it came from this app's userData, a packaged Orca,
// or a different dev build). Treat it as if ZDOTDIR were unset so the caller
// falls back to HOME for the user's real config root.
// Why: an inherited ZDOTDIR pointing at an Orca wrapper dir (`.../shell-ready/zsh`) makes the wrapper source itself recursively (zsh recursion limit); treat it as unset so the caller falls back to HOME.
function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
if (!value) {
return null
}
// Why: tolerate trailing slashes — some shell startup scripts export
// `ZDOTDIR="$dir/"`, and without normalization the suffix check would
// miss the self-loop path and restore the recursion bug. Also collapses
// a pathological `ZDOTDIR=/` to empty so we fall back to HOME rather than
// sourcing `/.zshenv` (which is never the user's real config).
// Why: strip trailing slashes so `ZDOTDIR="$dir/"` still matches the self-loop suffix check; `/` collapses to empty → HOME fallback.
const normalized = value.replace(/\/+$/, '')
if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
return null
@@ -349,10 +326,7 @@ ${getZshFinalZdotdirRestoreBlock()}
chmodSync(path, 0o644)
}
} catch (error) {
// Why: wrapper file creation can fail due to read-only filesystems, permission
// issues, or disk space. Rather than crashing, log the error and continue.
// The shell will launch without the wrapper, which means no shell-ready marker
// but at least the PTY is usable.
// Why: degrade gracefully — a failed wrapper (read-only FS, perms, disk) just means no ready marker, PTY stays usable.
const errorMessage =
error instanceof Error
? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})`
@@ -445,9 +419,7 @@ export function writeStartupCommandWhenShellReady(
proc: pty.IPty,
startupCommand: string,
onExit: (cleanup: () => void) => void,
// Why: only Orca-wrapped bash/zsh have bracketed-paste mode active, so
// multiline startup commands are wrapped in ESC[200~/ESC[201~ only there;
// other shells keep the raw submit path to avoid echoing the markers.
// Why: only Orca-wrapped bash/zsh have bracketed-paste active; other shells use the raw path to avoid echoing the ESC[200~ markers.
options: { bracketedPasteSafe?: boolean } = {}
): void {
let sent = false
@@ -475,22 +447,10 @@ export function writeStartupCommandWhenShellReady(
clearTimeout(postReadyTimer)
postReadyTimer = null
}
// Why: run startup commands inside the same interactive shell Orca keeps
// open for the pane. Spawning `shell -c <command>; exec shell -l` would
// avoid the race, but it would also replace the session after the agent
// exits and break "stay in this terminal" workflows.
// Why CR on Windows: PowerShell's PSReadLine and cmd.exe submit the line
// on CR (`\r`) — a bare LF leaves the command typed at the prompt but
// unsubmitted, forcing the user to press Enter after Orca launches the
// agent or setup script. POSIX shells (bash/zsh) treat either CR or LF as
// Enter under ICRNL, so CR works there too, but this code path is reached
// on Windows as well as POSIX via writeStartupCommandWhenShellReady.
// Why: run in the same interactive shell (not `shell -c`) so the session survives after the agent exits.
// Why CR on Windows: PSReadLine/cmd.exe submit on `\r`, not LF; POSIX treats either as Enter under ICRNL.
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: startup commands are usually long, quoted agent launches. Writing
// them in one PTY call after the shell-ready barrier avoids the incremental
// paste behavior that still dropped characters in practice. Multiline
// prompts are additionally wrapped in bracketed paste (see the helper) so
// embedded newlines are inserted literally instead of submitting early.
// Why: single write after the ready barrier avoids incremental-paste char drops; multiline is bracketed-paste wrapped so newlines don't submit early.
proc.write(
buildStartupCommandSubmission(startupCommand, {
submit,
@@ -507,16 +467,7 @@ export function writeStartupCommandWhenShellReady(
if (sent) {
return
}
// Why: the shell-ready marker fires from precmd/PROMPT_COMMAND,
// before the prompt is drawn and before zle/readline switches the PTY into
// raw mode. Writing the command while the kernel still has ECHO enabled
// causes the characters to be echoed once by the kernel and then redisplayed
// by the line editor after the prompt — producing a visible duplicate.
//
// Strategy: if the marker-completing scan already observed post-marker
// bytes, use the short settle delay directly. Otherwise, wait for the next
// PTY data event after the ready marker, with a conservative fallback for
// ambiguous marker-only or markerless cases.
// Why: marker fires from precmd before the line editor takes the PTY out of ECHO; writing now double-echoes the command, so settle first.
if (signal?.postMarkerBytesObserved === true) {
schedulePostReadyFlush()
return
+20 -61
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this module keeps Claude credential source
ordering, OAuth usage fetch semantics, and PTY fallback behavior together so
subscription usage state cannot drift across code paths. */
/* eslint-disable max-lines -- Why: keep Claude credential ordering, OAuth usage fetch, and PTY fallback together so usage state can't drift across paths. */
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
@@ -53,13 +51,7 @@ const LIVE_CLAUDE_REFRESH_DEFERRED_MESSAGE =
/**
* Bridge standard HTTP proxy env vars into Electron's session proxy config.
*
* Why: Electron's net.fetch uses Chromium's networking stack which respects
* OS-level proxy settings but ignores HTTP_PROXY / HTTPS_PROXY env vars.
* Users in regions where api.anthropic.com is only reachable via proxy (see
* #521, #800) often set these env vars rather than configuring system proxy.
* Without this bridge, the usage indicator silently fails and the app may hit
* Anthropic from an unexpected IP, risking rate-limit signals on the account.
* Why: net.fetch ignores HTTP_PROXY/HTTPS_PROXY; users behind a proxy for api.anthropic.com set those env vars (#521, #800).
*/
async function ensureProxyFromEnv(): Promise<void> {
await ensureElectronProxyFromEnvironment({
@@ -94,8 +86,6 @@ type OAuthCredentialReadOptions = {
type OAuthCredentialSource = 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none'
// Why: factored out so both the active-account Keychain reader and the
// managed-account reader share the same JSON parsing + refreshability check.
function parseOAuthCredentialsJson(
raw: string,
source: OAuthCredentialSource
@@ -113,9 +103,7 @@ function parseOAuthCredentialsJson(
source
}
}
// Why: Claude's local expiresAt metadata is not authoritative for the
// /api/oauth/usage endpoint. Real Claude Code 2.1 credentials have been
// observed authenticating there after expiresAt, so let the server decide.
// Why: local expiresAt isn't authoritative for /api/oauth/usage (creds authenticate there after expiry); let the server decide.
return {
token,
hasRefreshableCredentials,
@@ -145,8 +133,7 @@ function keychainUnavailableOAuthCredentialReadResult(): OAuthCredentialReadResu
/**
* Read OAuth token from macOS Keychain.
* Why: Claude Code 2.1+ scopes OAuth Keychain services by CLAUDE_CONFIG_DIR;
* older builds used the legacy unsuffixed service. The shared reader handles both.
* Why: Claude Code 2.1+ scopes Keychain services by CLAUDE_CONFIG_DIR; older builds used the legacy unsuffixed service.
*/
async function readFromKeychain(configDir?: string): Promise<OAuthCredentialReadResult> {
if (process.platform !== 'darwin') {
@@ -159,9 +146,7 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
return scopedCredentials
}
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
// Why: Orca cannot refresh tokens itself, so an actual access token from
// either item beats refresh-only credentials. A scoped item the CLI stopped
// maintaining must not shadow a still-working legacy token.
// Why: a real access token beats refresh-only creds (Orca can't refresh), so a stale scoped item can't shadow a working legacy token.
if (legacyCredentials.token) {
return legacyCredentials
}
@@ -202,8 +187,7 @@ async function readCredentialsFromStrictKeychain(
/**
* Read OAuth token from ~/.claude/.credentials.json (legacy path).
* Why: older Claude CLI versions store credentials in this plain JSON
* file. We keep it as a fallback for compatibility.
* Why: older Claude CLI versions store credentials here; kept as a fallback.
*/
async function readFromCredentialsFile(configDir?: string): Promise<OAuthCredentialReadResult> {
const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json')
@@ -217,9 +201,7 @@ async function readFromCredentialsFile(configDir?: string): Promise<OAuthCredent
/**
* Try credential sources that yield a genuine OAuth bearer token.
* Why: we intentionally do NOT read ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY
* here those are API keys which return 401 on the OAuth usage endpoint.
* API-key users are served by the PTY fallback instead.
* Why: skip ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY those are API keys that 401 on the OAuth usage endpoint (PTY fallback serves them).
*/
async function readOAuthCredentials(
options?: OAuthCredentialReadOptions
@@ -255,8 +237,7 @@ function resolveOAuthCredentialReadOptions(
if (!authPreparation) {
return undefined
}
// Why: Claude Code 2.1+ can scope even the default config dir's macOS
// Keychain item. Try scoped first, with legacy still handled as fallback.
// Why: Claude Code 2.1+ can scope even the default config dir's Keychain item; try scoped first, legacy as fallback.
const readOptions: OAuthCredentialReadOptions = {
credentialsFileConfigDir: authPreparation.configDir,
keychainConfigDir: authPreparation.configDir
@@ -403,8 +384,7 @@ function mapWindow(
}
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
// Why: model quotas moved into structured scoped limits; prefer that current
// contract while retaining explicit legacy weekly fields for older responses.
// Why: model quotas moved to structured scoped limits; prefer them but keep legacy weekly fields for older responses.
const scoped = Array.isArray(data.limits)
? data.limits.find(
(limit) =>
@@ -435,21 +415,18 @@ async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise<Provi
return abortedClaudeRateLimitResult()
}
// Compose the caller's cancel signal with the request timeout so a timeout
// and an external cancel both abort the fetch.
// Compose caller cancel with the request timeout so either aborts the fetch.
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
try {
// Why: net.fetch uses Chromium's networking stack which respects OS proxy
// settings and certificates. Env var proxies are bridged by ensureProxyFromEnv.
// Why: net.fetch uses Chromium's stack for OS proxy/certs; env-var proxies are bridged by ensureProxyFromEnv.
const res = await net.fetch(OAUTH_USAGE_URL, {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': OAUTH_BETA_HEADER,
// Why: Claude's OAuth usage endpoint is the Claude Code usage API;
// matching the CLI user-agent keeps Orca aligned with that contract.
// Why: match the Claude Code CLI user-agent to stay aligned with the OAuth usage API contract.
'User-Agent': CLAUDE_CODE_USER_AGENT
},
signal: requestSignal
@@ -588,9 +565,7 @@ function canSupplementOAuthUsageFromCli(input: {
authPreparation?: ClaudeRuntimeAuthPreparation
allowUsagePanelSupplement: boolean
}): boolean {
// Why: Fable is visible in Claude's interactive /usage panel even when the
// OAuth usage endpoint only reports documented 5h/7d windows. This runs only
// after OAuth succeeds, so it must not become a broad auth-recovery fallback.
// Why: Fable shows in Claude's /usage panel even when the OAuth endpoint reports only 5h/7d windows; supplement only after OAuth already succeeded.
return Boolean(
input.allowUsagePanelSupplement &&
!input.authPreparation?.managedRefreshDeferredByLivePty &&
@@ -677,11 +652,7 @@ function canRetryWithLegacyKeychainToken(input: {
oauthCredentials: OAuthCredentialReadResult
authPreparation?: ClaudeRuntimeAuthPreparation
}): boolean {
// Why: the CLI only maintains the legacy keychain item for the default config
// dir, so a scoped item can hold a token that expired long ago and will 401
// on every fetch with no recovery path. Host system-default auth may retry
// with the legacy item; managed/WSL credentials must never be answered with
// the host user's legacy keychain account.
// Why: only host auth may fall back to the legacy keychain item when a scoped item holds a dead token that 401s forever; managed/WSL must never use the host's legacy account.
return (
input.classification.failureKind === 'stale-token' &&
input.oauthCredentials.source === 'scoped-keychain' &&
@@ -1071,9 +1042,7 @@ type ManagedCredentialsLocation =
| { kind: 'keychain'; accountId: string; managedAuthPath: string }
| { kind: 'file'; managedAuthPath: string }
// Why: resolves where an inactive account's credentials live without
// materializing them into the shared runtime location. Using
// ClaudeRuntimeAuthService would overwrite the active account's auth.
// Why: resolve where inactive credentials live without materializing them — ClaudeRuntimeAuthService would overwrite the active account's auth.
function resolveManagedCredentialsLocation(
account: InactiveClaudeAccountInfo
): ManagedCredentialsLocation | null {
@@ -1087,8 +1056,7 @@ function resolveManagedCredentialsLocation(
if (!managedAuthPath) {
return null
}
// macOS stores host managed credentials in the Keychain; everything else
// (and WSL, handled above) stores them as a file under the managed dir.
// macOS stores host managed credentials in the Keychain; other platforms use a file under the managed dir.
if (process.platform === 'darwin') {
return { kind: 'keychain', accountId: account.id, managedAuthPath }
}
@@ -1201,9 +1169,7 @@ function canTrustManagedUsagePanelSupplement(
? windowsAgree(oauthLimits.weekly, cliLimits.weekly)
: null
].filter((match): match is boolean => match !== null)
// Why: macOS inactive previews temporarily stage managed credentials in a
// scoped Keychain item. If an older Claude build ignores scoped Keychains,
// matching OAuth windows prevent active-account Fable data from leaking in.
// Why: an older Claude build may ignore the scoped Keychain, so require matching OAuth windows to keep active-account Fable data from leaking in.
return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean)
}
@@ -1298,11 +1264,7 @@ export async function fetchManagedAccountUsage(
}
}
// Why: own the refresh for inactive accounts (claude-swap's model) — when the
// stored token is expiring, refresh and persist the rotated token back to
// managed storage before fetching usage. This keeps inactive accounts'
// single-use refresh tokens fresh so a later switch-in never materializes a
// stale token. Persistence failure is non-fatal: we still try the fetch.
// Why: refresh+persist an expiring token now so inactive accounts' single-use refresh tokens stay fresh for a later switch-in (persist failure is non-fatal).
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
if (isOauthTokenExpiring(credentialsJson)) {
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
@@ -1313,8 +1275,7 @@ export async function fetchManagedAccountUsage(
try {
await writeManagedCredentialsJson(location, refreshed)
} catch {
// Keep going with the refreshed token in memory even if the write
// failed; worst case the next poll refreshes again.
// Keep the refreshed token in memory; next poll refreshes again if the write failed.
}
credentialsJson = refreshed
token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token
@@ -1332,9 +1293,7 @@ export async function fetchManagedAccountUsage(
}
}
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
// path is used only as a supplement after OAuth succeeds, and it points
// directly at the managed account's isolated config so selection is unchanged.
// Why: no PTY fallback for inactive accounts — PTY only supplements after OAuth succeeds.
const oauthLimits = await fetchViaOAuth(token, options.signal)
if (options.signal?.aborted) {
return abortedClaudeRateLimitResult()
+25 -69
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: keeping both Codex RPC and PTY fallback
paths together in one file makes it easier to audit the protocol/parsing
differences and ensure account-scoped env handling stays identical. */
/* eslint-disable max-lines -- Why: keep Codex RPC and PTY fallback paths together to audit protocol/parsing differences and shared account-scoped env handling. */
import type {
CodexRateLimitResetOutcome,
ProviderRateLimits,
@@ -35,8 +33,7 @@ const RPC_TIMEOUT_MS = 10_000
const WSL_RPC_TIMEOUT_MS = 25_000
const PTY_TIMEOUT_MS = 15_000
const BACKEND_TIMEOUT_MS = 10_000
// Why: redeeming a reset credit is an explicit user action, not a background
// poll — give it more room before failing so a slow backend can still finish.
// Why: redeeming a reset credit is an explicit user action, not a poll — allow more time for a slow backend.
const REDEEM_BACKEND_TIMEOUT_MS = 30_000
const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
@@ -78,8 +75,7 @@ type RpcRateLimitsResult = {
secondary?: RpcRateWindow
}
// Why: the Codex app-server wraps rate limit data inside a `rateLimits` key.
// The actual response shape is `{ rateLimits: { primary, secondary, ... } }`.
// Why: the Codex app-server wraps rate limit data as { rateLimits: { primary, secondary, ... } }.
type RpcRateLimitsResponse = {
rateLimits?: RpcRateLimitsResult
rateLimitResetCredits?: {
@@ -166,14 +162,11 @@ function buildWslCodexCommand(
const execSuffix = `${args.map(shellQuote).join(' ')}${
options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
}`
// Why: npm/nvm Codex launchers use `#!/usr/bin/env node`. Resolving an
// absolute launcher in a login shell and later execing it from plain `sh`
// loses the PATH that supplies Node and also pins obsolete installations.
// Why: npm/nvm launchers use `#!/usr/bin/env node`; exec'ing them from plain sh loses Node's PATH and pins stale installs.
const loginShellCommand = buildWslLoginShellCommand(
[setupCommands, `exec codex ${execSuffix}`].join(' && ')
)
// Why: keep the outer sh non-login and hide RPC pipes before the configured
// shell startup can read input or print banners.
// Why: keep the outer sh non-login and hide RPC pipes before shell startup can read input or print banners.
const command = options?.isolateRpcStdio
? ['exec 3<&0', 'exec 4>&1', 'exec </dev/null', 'exec >/dev/null', loginShellCommand].join('\n')
: loginShellCommand
@@ -308,9 +301,7 @@ function getBackendAuthRead(
if (existing) {
return existing
}
// Why: the caller deadline must settle promptly, but Node cannot guarantee
// cancellation of an already-issued UNC read. Keep one raw read per auth
// path until the OS finishes so repeated quota refreshes cannot stack them.
// Why: Node can't cancel an in-flight UNC read; keep one read per auth path so repeated refreshes don't stack them.
const read = createAuthFilesystemOperation(authPath, () =>
readFile(authPath, 'utf8').then(
(content) => ({ content }),
@@ -375,8 +366,7 @@ async function fetchBackendRateLimitResetCredits(
if (signal.aborted) {
return null
}
// Why: published Codex 0.140 can read windows through app-server but strips
// reset-credit metadata that the backend already returns.
// Why: Codex 0.140's app-server strips the reset-credit metadata this backend endpoint still returns.
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
...auth,
signal
@@ -486,8 +476,7 @@ function mapRpcWindow(
return {
usedPercent: Math.min(100, Math.max(0, raw.usedPercent)),
// Why: Codex currently reports remaining minutes in `windowDurationMins`.
// Orca's UI needs the fixed bucket duration so labels stay "5h" / "wk".
// Why: windowDurationMins reports remaining minutes, but the UI needs the fixed bucket duration for "5h"/"wk" labels.
windowMinutes: expectedWindowMinutes,
resetsAt,
resetDescription
@@ -499,8 +488,7 @@ function mapBackendUsageWindow(
fallbackWindowMinutes: number
): RateLimitWindow | null {
const limitWindowSeconds = raw?.limit_window_seconds
// Match Codex backend-client's `window_minutes_from_seconds`: the backend
// field is the actual bucket duration and rounds partial minutes upward.
// Why: match Codex backend-client's window_minutes_from_seconds — actual bucket duration, rounding partial minutes up.
const windowMinutes =
typeof limitWindowSeconds === 'number' &&
Number.isFinite(limitWindowSeconds) &&
@@ -526,9 +514,7 @@ async function fetchViaBackend(
if (!auth || signal.aborted) {
return null
}
// Why: Codex itself reads this endpoint in backend-client's
// `get_rate_limit_status`; using the same contract avoids launching a hidden
// app-server (and a WSL login shell) for every routine quota refresh.
// Why: reuse Codex's own get_rate_limit_status endpoint, avoiding a hidden app-server (and WSL login shell) per refresh.
const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
...auth,
signal
@@ -538,9 +524,7 @@ async function fetchViaBackend(
return null
}
const payload = (await response.json()) as BackendUsageResponse
// `plan_type` is required by Codex's RateLimitStatusPayload. Reject a
// superficially successful but unrelated/malformed JSON response so the
// established app-server fallback still gets a chance.
// Why: plan_type is required by Codex's RateLimitStatusPayload; reject malformed JSON so the app-server fallback still runs.
if (typeof payload.plan_type !== 'string') {
return null
}
@@ -578,26 +562,18 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
const wslCodex = options?.codexHomePath
? buildWslCodexCommand(options.codexHomePath, codexArgs, { isolateRpcStdio: true })
: null
// Why: cold WSL process startup plus Codex app-server initialization can
// exceed the host RPC budget, causing a false "unavailable" on app launch.
// Why: cold WSL startup + app-server init can exceed the host RPC budget, causing a false "unavailable" on launch.
const rpcTimeoutMs = wslCodex ? WSL_RPC_TIMEOUT_MS : RPC_TIMEOUT_MS
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file.
// spawn() cannot execute batch scripts directly without shell:true, but
// shell:true with an args array triggers DEP0190 (args are concatenated,
// not escaped). Fix: detect batch scripts and route through cmd.exe /c.
// Why: .cmd/.bat launchers can't be spawned directly and shell:true triggers DEP0190 — route them through cmd.exe /c.
const { spawnCmd, spawnArgs } = wslCodex
? { spawnCmd: wslCodex.command, spawnArgs: wslCodex.args }
: getSpawnArgsForWindows(codexCommand, codexArgs)
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: resolveHiddenRateLimitPtyCwd(),
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
// Why windowsHide: this fetch runs periodically in the background;
// without the flag, cmd.exe /c would flash a console window for each
// poll on Windows.
// Why: scope the selected account to this subprocess only; never mutate process.env globally.
// Why windowsHide: without it, background cmd.exe /c polls flash a console window on Windows.
windowsHide: true,
env: {
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
@@ -663,11 +639,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
return id
}
// Why: the Codex RPC server follows the JSON-RPC/LSP initialization
// handshake: client sends `initialize` request, waits for the response,
// then sends an `initialized` notification. Only after that will the
// server accept other methods. Skipping the notification causes "Not
// initialized" errors on subsequent requests.
// Why: JSON-RPC/LSP handshake — send `initialized` after initialize or the server rejects methods as "Not initialized".
let rateLimitsId: number | null = null
const initId = sendRpc('initialize', {
@@ -699,8 +671,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
}
if (msg.id === initId) {
// Initialize succeeded — send `initialized` notification, then
// request rate limits.
// Initialize succeeded — send `initialized`, then request rate limits.
sendNotification('initialized')
rateLimitsId = sendRpc('account/rateLimits/read')
continue
@@ -800,8 +771,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
// PTY fallback — spawn `codex`, send `/status`, parse rendered output
// ---------------------------------------------------------------------------
// Why: these patterns match the Codex CLI's /status output format.
// "5h limit" and "Weekly limit" lines contain a percent and optional reset text.
// Why: match the Codex CLI /status output ("5h limit"/"Weekly limit" lines with a percent and optional reset text).
const FIVE_HOUR_RE = /5h\s+limit[:\s]*(\d+)%/i
const WEEKLY_RE = /weekly\s+limit[:\s]*(\d+)%/i
const RESET_TEXT_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i
@@ -851,13 +821,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
// Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows —
// those need cmd.exe as an interpreter. resolveCodexCommand() may also fall
// back to bare 'codex' when it can't locate the binary on disk, yet cmd.exe
// can still find codex.cmd via PATHEXT. Always route through cmd.exe on win32.
// Why not getSpawnArgsForWindows: the PTY path must route through cmd.exe
// even for bare 'codex' (not just .cmd/.bat) to let PATHEXT resolution
// succeed under a minimal Electron PATH. /d matches the rest of the codebase.
// Why: on win32 route through cmd.exe (even bare 'codex') so PATHEXT resolves codex.cmd under a minimal Electron PATH.
const isWin32 = process.platform === 'win32'
const spawnFile = wslCodex ? wslCodex.command : isWin32 ? getCmdExePath() : codexCommand
const spawnArgs = wslCodex ? wslCodex.args : isWin32 ? ['/d', '/c', codexCommand] : []
@@ -931,8 +895,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
const onDataDisposable = term.onData((data) => {
output += data
// Why: this background fallback only needs recent status output for
// parsing and diagnostics; cap noisy TUI output like the Claude fallback.
// Why: only recent status output is needed; cap noisy TUI output like the Claude fallback.
if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) {
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
}
@@ -946,8 +909,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
// Check if we have parseable output
if (sentStatus && !settleTimer && (FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output))) {
// Why: after status text is parseable the TUI may continue streaming
// chunks; one settle timer is enough to let the panel finish flushing.
// Why: the TUI keeps streaming after status is parseable; one settle timer lets the panel finish flushing.
settleTimer = setTimeout(() => {
settleTimer = null
if (resolved) {
@@ -1027,9 +989,7 @@ export async function fetchCodexRateLimits(
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
// Why: never spawn the `codex` binary unless the user has signed in. Without
// auth the RPC/PTY paths can only error, and spawning them shows up as an
// unexpected background Codex process for users who don't use Codex.
// Why: don't spawn `codex` unless signed in — otherwise non-Codex users see an unexpected background process that can only error.
const authPresence = await probeCodexAuthPresence(options?.codexHomePath, {
signal: options?.signal
})
@@ -1060,9 +1020,7 @@ export async function fetchCodexRateLimits(
}
}
// Path A (WSL): use Codex's own backend usage contract. Host accounts retain
// app-server's token-refresh/custom-CA behavior; WSL avoids starting a login
// shell just to reconstruct the CLI environment for a routine poll.
// Path A (WSL): use Codex's backend usage contract so a routine poll skips spawning a login shell to rebuild the CLI env.
if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) {
try {
const backendResult = await fetchViaBackend(options)
@@ -1077,8 +1035,7 @@ export async function fetchCodexRateLimits(
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
// Token refresh, network routing, and custom-CA behavior can differ from
// the host fetch stack. Preserve the CLI paths as compatibility fallbacks.
// Why: token refresh, network routing, and custom-CA behavior can differ from the host fetch stack; keep CLI paths as fallbacks.
}
}
@@ -1098,8 +1055,7 @@ export async function fetchCodexRateLimits(
if (options?.allowPtyFallback === false) {
return rpcResult
}
// Why: app-server can fail independently of the interactive CLI. Keep the
// status-bar useful by trying the older /status PTY reader on RPC errors.
// Why: app-server can fail independently of the interactive CLI; fall back to the /status PTY reader on RPC errors.
} catch {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
+42 -111
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this service centralizes polling, stale-data
handling, account-switch fetch semantics, and renderer push coordination so the
fetch ordering rules stay in one place. */
/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */
import type { BrowserWindow } from 'electron'
import { randomUUID } from 'node:crypto'
import type {
@@ -69,23 +67,16 @@ type ActiveWindowRefreshPlan =
| { kind: 'full' }
| { kind: 'providers'; providers: ActiveRateLimitProvider[] }
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
// state is informational, so prefer keeping a recent snapshot over polling it
// into 429s during long focused Orca sessions.
// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s.
const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes
const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop.
const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms.
const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts
const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS
// Why: a persistent failure (bad auth, unsupported plan) retried at the 30s
// floor hammers provider endpoints — Claude's tight-budget usage endpoint
// starts returning 429s — without ever recovering. Back off per consecutive
// failure, capped at the background poll cadence.
// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence.
const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS
const MAX_ACTIVE_FAILURE_STREAK = 8
// Why: these providers have a dedicated fetch cycle, so an activation retry can
// refresh just the failing one. Providers without one force a full fetchAll, so
// their error retries stay on the 5-minute cadence to protect Claude's budget.
// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll.
const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet<ActiveRateLimitProvider> = new Set([
'claude',
'codex',
@@ -95,8 +86,7 @@ const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale da
const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open
const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000
// Why: inactive account arrays are derived from provider-specific caches on
// demand in getState() and pushToRenderer().
// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer().
type InternalRateLimitState = {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
@@ -118,8 +108,7 @@ function normalizePollingInterval(ms: number): number {
function isSystemDefaultClaudeAuth(
authPreparation: ClaudeRuntimeAuthPreparation | undefined
): boolean {
// Why: fetch cycles classify missing Claude auth as system-default; keep the
// PTY fallback gate aligned so background refresh cannot trigger auth flows.
// Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows.
if (!authPreparation) {
return true
}
@@ -146,8 +135,7 @@ export class RateLimitService {
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType<typeof setInterval> | null = null
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
// Why: after the first recovery attempt, repeated focus/show/restore events
// during the same outage should not create a tight provider retry loop.
// Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop.
private lastActiveFailureRetryAtByProvider: Record<ActiveRateLimitProvider, number> = {
claude: 0,
codex: 0,
@@ -158,8 +146,7 @@ export class RateLimitService {
grok: 0,
antigravity: 0
}
// Why: consecutive applied failures per provider drive exponential backoff of
// the fast activation-retry lane; reset on any successful/unavailable result.
// Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result.
private activeFailureStreakByProvider: Record<ActiveRateLimitProvider, number> = {
claude: 0,
codex: 0,
@@ -269,8 +256,7 @@ export class RateLimitService {
const refreshOnResume = (): void => {
void this.refreshIfWindowActive()
}
// Why: attach() can replace windows; the previous closed listener also
// captures this service and must be removed with the focus listeners.
// Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners.
const detachWindowListeners = (): void => {
mainWindow.removeListener('focus', refreshOnResume)
mainWindow.removeListener('show', refreshOnResume)
@@ -320,9 +306,7 @@ export class RateLimitService {
this.pruneInactiveCodexState()
return {
...this.state,
// Why: the cookie lives in the file system, not GlobalSettings. Surface
// its presence on the pushed state so the renderer keeps the MiniMax
// bar visible across reloads and between snapshot refreshes.
// Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads.
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
grokAuthConfigured: this.grokAuthConfigured,
claudeTarget: this.claudeFetchTarget,
@@ -339,17 +323,13 @@ export class RateLimitService {
}
async refresh(): Promise<RateLimitState> {
// Why: the explicit refresh button is a user-directed recovery action.
// Debouncing it behind the background poll throttle makes the UI feel
// broken after wake/focus transitions because the click can no-op even
// though the user is asking for a fresh read right now.
// Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken.
await this.fetchAll({ force: true })
return this.getState()
}
async refreshIfStale(): Promise<RateLimitState> {
// Why: reconnecting mobile subscribers need fresh backgrounded-desktop data,
// but replaying a subscription must not queue another forced provider fetch.
// Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch.
const plan = this.getActiveWindowRefreshPlan(Date.now())
await this.runActiveWindowRefreshPlan(plan)
return this.getState()
@@ -362,8 +342,7 @@ export class RateLimitService {
invalidateMiniMaxCredentialState(): void {
this.minimaxFetchGeneration += 1
// Why: saving or forgetting the browser cookie can race an in-flight usage
// fetch; clear the visible snapshot before any old-cookie result returns.
// Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns.
this.updateState({
...this.state,
minimax: this.withFetchingStatus(null, 'minimax')
@@ -389,9 +368,7 @@ export class RateLimitService {
this.inactiveCodexAccountsGeneration += 1
this.pruneInactiveCodexState()
this.lastInactiveCodexFetchAt = 0
// Why: switching the selected Codex account must immediately clear the old
// Codex quota view. Keeping stale values visible would show the previous
// account's limits under the newly selected identity until the next poll.
// Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll.
this.updateState({
...this.state,
codex: this.withFetchingStatus(null, 'codex')
@@ -442,8 +419,7 @@ export class RateLimitService {
target?: ClaudeAccountSelectionTarget
): Promise<RateLimitState> {
const nextTarget = normalizeClaudeAccountSelectionTarget(target)
// Why: snapshot the outgoing account's usage before clearing it so the
// inline usage bars in the switcher can show last-known data immediately.
// Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately.
if (
outgoingAccountId &&
this.state.claude?.session &&
@@ -536,8 +512,7 @@ export class RateLimitService {
const cached = this.inactiveClaudeCache.get(account.id) ?? null
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one Keychain rejection or
// network error from aborting the remaining accounts in the batch.
// Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch.
if (
signal.aborted ||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
@@ -570,8 +545,7 @@ export class RateLimitService {
if (accounts.length === 0) {
return
}
// Why: account switching can make a previewed account active while its
// RPC-only usage fetch is still in flight; stale results must be ignored.
// Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results.
const fetchGeneration = this.inactiveCodexAccountsGeneration
const controller = this.beginFetchCycle()
const signal = controller.signal
@@ -596,12 +570,8 @@ export class RateLimitService {
continue
}
try {
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
// point it at the managed account's home directory directly without
// materializing credentials into the shared runtime location.
// Why: opening the account switcher should never start hidden PTYs for
// every inactive account. On Windows that fallback can crash inside
// ConPTY; RPC-only is enough for this non-critical preview surface.
// Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location.
// Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough.
const fresh = await fetchCodexRateLimits({
codexHomePath: account.managedHomePath,
allowPtyFallback: false,
@@ -695,11 +665,7 @@ export class RateLimitService {
}
evictInactiveCodexCache(accountId: string): void {
// Why: only the evicted account's state should be cleared. The per-account
// isCurrentInactiveCodexAccount guard in fetchInactiveCodexAccountsOnOpen
// already catches a removed account when its resolver entry disappears,
// so bumping the generation here would also invalidate sibling fetches
// still in flight and discard their fresh results.
// Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results.
this.inactiveCodexCache.delete(accountId)
this.inactiveCodexFetching.delete(accountId)
this.pushToRenderer()
@@ -753,9 +719,7 @@ export class RateLimitService {
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
return false
}
// Why: these quota fetches only power in-app UI. When Orca is hidden,
// minimized, or unfocused, polling only burns CLI/API budget without any
// visible benefit. We refresh again as soon as the window becomes active.
// Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate).
if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) {
return false
}
@@ -763,9 +727,7 @@ export class RateLimitService {
}
private getActiveProviderState(): ActiveProviderState[] {
// Why: key by provider so a newly added provider is compile-forced to have
// an active-refresh entry — a missing one silently never recovers from a
// startup error (antigravity was omitted once and needed a fix-up).
// Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error.
const byProvider: Record<ActiveRateLimitProvider, ProviderRateLimits | null> = {
claude: this.state.claude,
codex: this.state.codex,
@@ -794,8 +756,7 @@ export class RateLimitService {
}
continue
}
// Why: a failed startup read is not fresh data. Keep it eligible for
// activation recovery while throttling repeated events per provider.
// Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider.
if (limits.status === 'error') {
const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider]
const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider)
@@ -822,10 +783,8 @@ export class RateLimitService {
return
}
if (plan.kind === 'full') {
// Why: a full fetch retries failing providers too. Restart their retry
// clocks so the individual failure lane doesn't fire again right after,
// ahead of its backoff window. Skip when a fetch is already in flight —
// fetchAll would no-op and the throttle must not be consumed for free.
// Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff.
// Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free.
if (!this.isFetching) {
const now = Date.now()
for (const { provider, limits } of this.getActiveProviderState()) {
@@ -838,8 +797,7 @@ export class RateLimitService {
return
}
// Why: a fetch already in flight will refresh these providers; skip without
// consuming the per-provider retry throttle so the next activation retries.
// Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries.
if (this.isFetching) {
return
}
@@ -857,8 +815,7 @@ export class RateLimitService {
return
}
// Why: partial failures of providers with a dedicated fetch cycle should
// recover without re-reading healthy providers still inside their debounce.
// Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce.
if (plan.providers.includes('claude')) {
await this.fetchClaudeOnly()
}
@@ -1124,9 +1081,7 @@ export class RateLimitService {
) {
return Promise.resolve()
}
// Why: explicit refresh callers need to await the queued follow-up cycle
// when a poll is already in flight, otherwise the UI stops spinning before
// the user-requested refresh actually runs.
// Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early.
return new Promise((resolve) => {
this.fetchIdleResolvers.push(resolve)
})
@@ -1232,28 +1187,23 @@ export class RateLimitService {
}
private shouldAllowCodexPtyFallback(): boolean {
// Why: quota UI refreshes run in the background. On Windows, hidden PTY
// fallback can crash inside ConPTY, so prefer RPC-only degradation there.
// Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh.
return process.platform !== 'win32'
}
private shouldAllowClaudePtyFallback(
authPreparation: ClaudeRuntimeAuthPreparation | undefined
): boolean {
// Why: automatic recovery uses Claude CLI as the next source, but Windows
// hidden PTY support remains less reliable than host/WSL shells.
// Why: Windows hidden PTY support is less reliable than host/WSL shells.
if (process.platform === 'win32') {
return false
}
// Why: system-default Claude is not an Orca-managed account. Background
// quota refresh may read existing OAuth, but must not launch Claude and
// trigger auth/browser flows for users who never configured Claude in Orca.
// Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows.
return !isSystemDefaultClaudeAuth(authPreparation)
}
private shouldAllowClaudeUsagePanelSupplement(): boolean {
// Why: this supplement runs only after OAuth has already returned usage
// data. Keep it off on Windows where hidden PTYs are still less reliable.
// Why: keep this supplement off on Windows where hidden PTYs are still less reliable.
return process.platform !== 'win32'
}
@@ -1268,8 +1218,7 @@ export class RateLimitService {
error: null
}
} catch (error) {
// Why: one unreadable browser cookie must not abort every provider's
// quota refresh; surface it as MiniMax-only state instead.
// Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead.
return {
config: {
sessionCookie: '',
@@ -1331,10 +1280,7 @@ export class RateLimitService {
status: 'fetching'
}
}
// Why: repainting a settled chip as "fetching" on every background refetch
// makes the status bar flash "…" → error each retry cycle when a provider
// is persistently failing. Keep the settled state visible until the new
// result lands; only providers with no settled state show a loading chip.
// Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle.
if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') {
return current
}
@@ -1365,13 +1311,11 @@ export class RateLimitService {
const miniMaxGroupId = miniMaxConfigResult.config.groupId
const miniMaxModels = miniMaxConfigResult.config.models
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
// Why: getState() is used by renderer pushes and mobile snapshots; keep
// Grok's sync auth-file probe on fetch cycles instead of every state read.
// Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead.
const grokAuthReadResult = readGrokAuthSession()
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
// Detect if configuration changed — if it did, we must discard any stale
// data because it belongs to a different session/workspace.
// Discard stale data on config change — it belongs to a different session/workspace.
const currentConfigHash = `${cookie}|${workspaceIdOverride}`
const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash
if (opencodeConfigChanged) {
@@ -1388,9 +1332,7 @@ export class RateLimitService {
}
const miniMaxGeneration = this.minimaxFetchGeneration
// Mark all providers as fetching while keeping previous data visible.
// Codex account changes clear Codex separately before this method is
// called, so ordinary refreshes still preserve the current values.
// Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change).
this.updateState({
...previousState,
claude: this.withFetchingStatus(previousState.claude, 'claude'),
@@ -1488,9 +1430,7 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
// Why: Antigravity shares Google/Gemini usage credentials today; mirror the
// Gemini snapshot under provider 'antigravity' so status-bar UI that checks
// antigravity state receives a real fetch lifecycle instead of staying null.
// Why: Antigravity shares Gemini credentials today; mirror the Gemini snapshot so its status-bar UI gets a real lifecycle instead of null.
const antigravity: ProviderRateLimits = {
...gemini,
provider: 'antigravity'
@@ -1571,10 +1511,7 @@ export class RateLimitService {
this.trackActiveFailureStreak('minimax', miniMax)
}
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
// generation still match, otherwise an old account could overwrite the
// newly selected account's quota state.
// Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account.
this.updateState({
...this.state,
claude: shouldApplyClaude
@@ -1789,8 +1726,7 @@ export class RateLimitService {
}
}
// Explicitly unavailable — user likely cleared a setting. Discard any stale
// data so the UI reflects that the provider is now disabled/unconfigured.
// Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured.
if (fresh.status === 'unavailable') {
return fresh
}
@@ -1813,11 +1749,7 @@ export class RateLimitService {
return fresh
}
// Why: once we have a recent successful snapshot, repeated transient
// failures should keep showing that same snapshot until it ages out of the
// stale window. Otherwise the bar flaps from "stale but useful" to empty
// after the second failure even though the last known quota is still fresh
// enough to be actionable.
// Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty.
return {
...previous,
error: fresh.error,
@@ -1844,8 +1776,7 @@ export class RateLimitService {
isFetching: fetching.has(accountId)
})
}
// Why: include accounts that are fetching but have no cache yet so the
// renderer can show a loading indicator for newly added accounts.
// Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts.
for (const accountId of fetching) {
if (!cache.has(accountId)) {
result.push({
+23 -79
View File
@@ -1,16 +1,5 @@
/**
* Tests for the presence-based mobile driver lock.
*
* Covers (per docs/mobile-presence-lock.md):
* - State machine transitions (idle | desktop | mobile{clientId})
* - Most-recent-actor wins for active phone-fit dims
* - Earliest-by-subscribe-time wins for desktop-restore target
* - Subscribe-in-desktop-mode is a passive watch (does NOT take floor)
* - mobileTookFloor re-applies phone-fit when transitioning from desktop
* - reclaimTerminalForDesktop idempotent, drops banner, restores dims
* - Multi-mobile sequencing: A subscribes / B subscribes / B unsubscribes /
* A unsubscribes leaves the runtime cleanly idle without dim regressions
* - terminalDriverChanged notifications fire at the right transitions
* Tests for the presence-based mobile driver lock (see docs/mobile-presence-lock.md).
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
@@ -66,17 +55,12 @@ const store = {
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
// Why: legacy mobile tests pre-date the fit-hold preference. Default
// to MIN (the new clamp floor) so the auto-restore behavior they assert
// continues to fire after a finite delay. Real getDefaultSettings()
// is null/indefinite. See docs/mobile-fit-hold.md.
// Why: default to a finite 5s (not the real null/indefinite) so legacy auto-restore tests still fire. See docs/mobile-fit-hold.md.
mobileAutoRestoreFitMs: 5_000
})
}
// Why (#7588): the held-modal repro needs indefinite hold (null) while the
// legacy tests rely on the finite 5s default. Wrap getSettings per-test so a
// caller can pick the hold without mutating the shared stub.
// Why (#7588): held-modal repro needs indefinite hold (null); legacy tests need the finite 5s default. Wrap per-test without mutating the shared stub.
function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) {
const effectiveStore = {
...store,
@@ -159,8 +143,7 @@ describe('mobile presence lock — driver state machine', () => {
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// Driver stays idle (the desktop banner was already gone). Phone is
// "passively watching" at desktop dims.
// Driver stays idle the phone is passively watching at desktop dims.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
expect(driverEvents.find((e) => e.driver.kind === 'mobile')).toBeUndefined()
})
@@ -173,8 +156,7 @@ describe('mobile presence lock — driver state machine', () => {
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
// Idempotent — second call is a no-op (no active mobile subscriber to
// reclaim from).
// Idempotent — second call is a no-op (no active mobile subscriber left to reclaim from).
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
})
@@ -280,9 +262,7 @@ describe('mobile presence lock — driver state machine', () => {
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
// Why: soft-leave grace keeps driver=mobile{phone-A} for ~250ms so a
// re-subscribe (e.g. mobile keyboard show/hide on legacy clients)
// doesn't cause a desktop banner flash.
// Why: soft-leave grace holds driver=mobile ~250ms so a keyboard show/hide re-subscribe doesn't flash the desktop banner.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
await vi.advanceTimersByTimeAsync(250)
@@ -297,8 +277,7 @@ describe('mobile presence lock — driver state machine', () => {
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// Same client re-subscribes inside the grace window — no idle should
// ever be observed by the renderer.
// Same client re-subscribes inside the grace window — renderer must never observe idle.
await vi.advanceTimersByTimeAsync(100)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(500)
@@ -412,8 +391,7 @@ describe('mobile presence lock — multi-mobile semantics', () => {
it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// Advance the fake clock so phone-B's subscribe records a strictly
// later subscribedAt/lastActedAt — keeps tie-break deterministic.
// Advance so phone-B's subscribe records a strictly later timestamp — keeps tie-break deterministic.
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
// Switch to desktop, then phone-B types — its viewport wins on re-fit.
@@ -421,8 +399,7 @@ describe('mobile presence lock — multi-mobile semantics', () => {
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
// Simulate B taking the floor by typing (advance again so lastActedAt
// is unambiguously the most recent).
// Advance so B's lastActedAt is unambiguously the most recent before it takes the floor.
await vi.advanceTimersByTimeAsync(10)
await runtime.mobileTookFloor('pty-1', 'phone-B')
@@ -464,12 +441,9 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 16 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// Why: a viewport update may re-emit driver to refresh listener
// wiring, but it must never go through `idle` (no banner flash).
// Why: a viewport update may re-emit driver but must never pass through idle (no banner flash).
expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true)
// Why: phone→phone dim ticks (keyboard show/hide) are the hottest layout
// path and must not wake the renderer's fit-override listeners — the
// emit gate opens only when layout kind or override presence changes.
// Why: phone→phone dim ticks (keyboard show/hide) are the hottest path — must not wake fit-override listeners (gate opens only on layout-kind/override-presence change).
expect(fitOverrideEvents.length).toBe(fitEventsBefore)
})
@@ -564,8 +538,7 @@ describe('mobile presence lock — multi-mobile semantics', () => {
// onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers.
await vi.advanceTimersByTimeAsync(0)
// PTY must restore to the original 150x40 baseline, not the last
// phone-fit dim. This was the stuck-dim bug.
// Must restore to the original 150x40 baseline, not the last phone-fit dim (the stuck-dim bug).
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
})
@@ -599,8 +572,7 @@ describe('mobile presence lock — multi-mobile semantics', () => {
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
// A leaves, B leaves — final restore must use A's earliest baseline (150x40),
// NOT B's (which captured 45x20 when it joined a phone-fitted PTY).
// Final restore must use A's earliest baseline (150x40), not B's (it captured 45x20 joining a phone-fitted PTY).
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
runtime.handleMobileUnsubscribe('pty-1', 'phone-B')
await vi.advanceTimersByTimeAsync(5_000)
@@ -609,20 +581,14 @@ describe('mobile presence lock — multi-mobile semantics', () => {
})
})
// Why (#7588): drive the runtime into the reported held-modal state — a phone
// fit that an indefinite hold left behind, followed by a null-viewport
// resubscribe (app update / WebView reload) that re-registers an active
// subscriber with wasResizedToPhone=false while the override is still held.
// This is the state where the desktop "Your phone left this at phone size"
// modal's Restore buttons used to silently no-op.
// Why (#7588): reproduce the held-modal state — a null-viewport resubscribe re-registers an active subscriber while the phone-fit override is still held (where Restore used to no-op).
async function reachHeldModalWithNullViewportResubscribe(
runtime: OrcaRuntimeService
): Promise<void> {
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
await vi.advanceTimersByTimeAsync(300)
// Production RPC passes `params.viewport` straight through, so a client
// that hasn't measured yet arrives here as `undefined`.
// Production RPC passes params.viewport straight through, so an unmeasured client arrives as undefined.
await runtime.handleMobileSubscribe('pty-1', 'phone-A', undefined)
}
@@ -630,9 +596,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
// Scenario 1: the reported repro end-to-end. Restore must converge and
// notify BOTH the renderer notifier and a runtime listener (paired), since
// remote/web viewers ride the listener channel.
// Scenario 1: reported repro end-to-end — restore must notify both channels since remote/web viewers ride the listener channel.
it('reclaim after a null-viewport resubscribe restores dims, clears override, notifies both channels', async () => {
const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -655,10 +619,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 2: a second click after success is an idempotent no-op. The
// persistent null-viewport subscriber keeps reclaim in the active-subscriber
// branch; assert only "returns true, no new PTY resize, no new fit-override
// event" — a benign mobile-facing mode-change notify is acceptable.
// Scenario 2: second reclaim is an idempotent no-op — the persistent null-viewport subscriber keeps it in the active-subscriber branch (benign mode-change notify OK).
it('second Restore click after success returns true with no new resize or fit-override event', async () => {
const { runtime, resizes, fitOverrideEvents } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -677,9 +638,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.length).toBe(listenerCount)
})
// Scenario 3: the existing driving take-back is unregressed — a phone that is
// actively driving (wasResizedToPhone=true) still flips to desktop, clears
// the override, and notifies both channels.
// Scenario 3: regression guard — an actively-driving phone (wasResizedToPhone=true) still converges to desktop.
it('driving take-back still converges: driver → desktop, override cleared, both channels notified', async () => {
const { runtime, ptySizes, fitOverrideEvents } = createRuntime()
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -699,12 +658,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 4: an explicit take-back on a HELD (no-subscriber) override must
// ALWAYS release, even when the desktop resize fails. The override is cleared
// optimistically with a paired desktop-fit 0×0 so the banner can't linger; the
// desktop renderer refits the PTY on its next settled frame. (The gesture
// deliberately overrides #7588's keep-lock-on-failed-resize rule, which still
// governs the auto-restore and phone-initiated paths.)
// Scenario 4: explicit held-override take-back always releases, even on failed resize — overrides #7588's keep-lock rule (which still governs the auto-restore/phone paths).
it('held restore with a failing resize still releases and clears the override', async () => {
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -729,10 +683,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 5: an explicit ACTIVE-SUBSCRIBER take-back with a failing resize
// must still release — driver → desktop, override cleared, banner dismissed,
// mode reset to 'auto'. This is the "take back all terminals" guarantee: a
// background PTY that can't converge must not strand its banner.
// Scenario 5: "take back all terminals" guarantee — an active-subscriber take-back with a failing resize must still release so a background PTY can't strand its banner.
it('active-subscriber take-back with a failing resize still releases the lock', async () => {
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime()
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -756,10 +707,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 5b: the failed-resize take-back leaves NO stranded phone-fit —
// driver released to desktop, override cleared, mode reset to 'auto'. (The
// pre-revision behavior kept the lock and relied on a later auto-restore; the
// explicit gesture now releases unconditionally.)
// Scenario 5b: explicit take-back releases unconditionally (pre-revision kept the lock for a later auto-restore).
it('failed take-back leaves no stranded override or lock', async () => {
const { runtime, setResizeSucceeds } = createRuntime()
@@ -773,8 +721,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
})
// Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale
// held override converges through the shared applyMobileDisplayMode seam.
// Scenario 6: phone-initiated desktop switch converges a stale held override via the shared applyMobileDisplayMode seam.
it('phone-initiated setDisplayMode(desktop) against a stale held override converges', async () => {
const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -796,10 +743,7 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
})
// Scenario 7 (white-box): a held override whose `layouts` entry is gone is
// unreachable via public APIs (onPtyExit deletes both in lockstep), so seed
// it directly. Reclaim must still delete the override and emit a paired
// desktop-fit 0×0 rather than stranding the modal on the next hydrate.
// Scenario 7 (white-box): a held override with no `layouts` entry is unreachable via public APIs (onPtyExit deletes both in lockstep), so seed it directly.
it('orphan cleanup: reclaim on a held override with no layout entry converges', async () => {
const { runtime, fitOverrideEvents } = createRuntime(null)
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
@@ -1,7 +1,5 @@
/**
* Integration tests for the server-authoritative mobile subscribe lifecycle.
* Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode,
* debounced restore, inline restore on timer cancel, and cleanup paths.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
@@ -50,11 +48,7 @@ vi.mock('../git/git-username', async () => {
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
// Why: many tests pre-date the mobileAutoRestoreFitMs preference. Default
// the mock store to MIN (5_000ms — the new clamp floor) so legacy
// assertions about "restore fires after the configured delay" keep their
// shape while new tests can override per-test. Indefinite/null is the
// real-world default and is exercised by a dedicated test below.
// Why: default the mock store to the clamp floor (5_000ms) so legacy "restore fires after delay" assertions hold; the real default is indefinite/null.
const LEGACY_RESTORE_MS = 5_000
const settingsState = {
mobileAutoRestoreFitMs: LEGACY_RESTORE_MS as number | null
@@ -233,8 +227,7 @@ describe('mobile subscribe integration', () => {
rows: 30,
seq: 17,
source: 'headless',
// Non-alt-screen buffer reports alternateScreen=false so the renderer
// keeps its destructive scrollback clear on restore.
// Non-alt-screen reports alternateScreen=false so the renderer keeps its destructive scrollback clear on restore.
alternateScreen: false
})
await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull()
@@ -292,9 +285,7 @@ describe('mobile subscribe integration', () => {
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
// Why: 'phone' (sticky-fit) mode was removed — there are now only 'auto'
// and 'desktop'. Auto-mode always restores on last unsubscribe. Test
// kept and inverted to lock in the new contract.
// Why: 'phone' sticky-fit mode was removed; auto-mode now always restores on last unsubscribe.
it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => {
const { runtime, ptySizes } = createRuntime()
// mode defaults to 'auto'
@@ -363,8 +354,7 @@ describe('mobile subscribe integration', () => {
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Re-subscribe to the same terminal (e.g., after reconnect)
// The PTY is already at 45x20, but previousDims should still be 150x40
// Re-subscribe (e.g. after reconnect): PTY is already 45x20 but previousDims must stay 150x40.
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Unsubscribe and let restore fire
@@ -376,13 +366,7 @@ describe('mobile subscribe integration', () => {
})
it('preserves held-override baseline across resubscribe-after-indefinite-hold', async () => {
// Why: when the last mobile subscriber leaves under indefinite hold,
// the inner-subscribers map is wiped but `terminalFitOverrides` retains
// the original desktop dims as previousCols/previousRows. A fresh
// resubscribe must inherit those — otherwise rendererSize/currentSize
// (both phone dims because the override held them) would replace the
// baseline with phone dims, and any subsequent desktop "Restore" would
// be a no-op (restore-target == current dims).
// Why: a fresh resubscribe under indefinite hold must inherit the override's retained desktop baseline, or a later desktop Restore becomes a no-op.
settingsState.mobileAutoRestoreFitMs = null // indefinite hold
const { runtime, ptySizes } = createRuntime()
@@ -390,8 +374,7 @@ describe('mobile subscribe integration', () => {
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Phone leaves; indefinite hold keeps PTY at phone dims with no
// subscribers. Override baseline still carries 150x40.
// Phone leaves; indefinite hold keeps PTY phone-fit while the override baseline still carries 150x40.
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
await vi.advanceTimersByTimeAsync(60_000)
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
@@ -568,9 +551,7 @@ describe('mobile subscribe integration', () => {
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
// Phone→desktop arms the 500ms renderer-cascade suppress window per
// docs/mobile-terminal-layout-state-machine.md. Wait it out before the
// renderer's correcting fit is allowed to update lastRendererSizes.
// Wait out the 500ms cascade-suppress armed by phone→desktop before the renderer's fit can update lastRendererSizes. See docs/mobile-terminal-layout-state-machine.md.
await vi.advanceTimersByTimeAsync(500)
// Simulate desktop renderer's safeFit correcting to split-pane width
@@ -605,30 +586,21 @@ describe('mobile subscribe integration', () => {
})
it('ignores reports while a mobile-fit override is in place', async () => {
// Why: while a mobile-fit override is in place, the PTY is parked at
// phone dims and the desktop renderer's safeFit will report those
// phone dims back to us. Treating that as "external" geometry would
// overwrite the subscriber's previousCols/Rows baseline with phone
// dims; resolveDesktopRestoreTarget would then return phone dims on
// the next "Restore" click, leaving xterm stuck at phone dims after
// a no-op desktop-restore. See docs/mobile-fit-hold.md.
// Why: while an override holds phone dims the renderer echoes them back; treating that echo as external geometry would poison the restore baseline. See docs/mobile-fit-hold.md.
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Renderer reports phone dims back (echo of override). Must be ignored.
runtime.onExternalPtyResize('pty-1', 45, 20)
// Toggle to desktop — restore lands on the original desktop baseline,
// not the phone-dim echo.
// Toggle to desktop — restore lands on the original baseline, not the phone-dim echo.
runtime.setMobileDisplayMode('pty-1', 'desktop')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('still updates baseline once the override is cleared (real desktop resize)', async () => {
// Counterpart to the above: after the user takes back, the renderer's
// pty:resize events ARE legitimate geometry reports and must update
// the baseline used by the next phone-fit cycle.
// Counterpart: once the override is cleared, pty:resize reports are legitimate and must update the baseline.
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
@@ -643,8 +615,7 @@ describe('mobile subscribe integration', () => {
// User resizes the desktop window. Renderer fires pty:resize.
runtime.onExternalPtyResize('pty-1', 130, 35)
// Toggle back to phone, then take back again — should restore to the
// updated desktop geometry, not the original 150x40.
// Toggle to phone then take back — should restore the updated geometry, not the original 150x40.
runtime.setMobileDisplayMode('pty-1', 'auto')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
@@ -655,23 +626,14 @@ describe('mobile subscribe integration', () => {
})
it('updates baseline mid-fit when desktop reports dims that differ from the override', async () => {
// Why: a previously-hidden desktop tab can become visible while the
// phone is still phone-fitting (e.g. user activates the tab on
// desktop). The pane's container goes 0×0 → real geometry, fitAddon
// measures, and pty:resize fires with REAL dims (not the override's
// phone dims). That report is legitimate and must refresh the
// restore baseline so take-back lands on the visible desktop
// geometry instead of whatever stale baseline the subscriber
// captured at first subscribe.
// Why: a desktop tab becoming visible mid-fit fires pty:resize with real dims (not the override's); that legit report must refresh the restore baseline.
const { runtime, ptySizes } = createRuntime()
// Pre-populate with an old/stale baseline (e.g. spawn default 80×24)
// by first reporting it before subscribe, then subscribing.
// Seed a stale baseline (spawn default 80×24) before subscribing.
ptySizes.set('pty-1', { cols: 80, rows: 24 })
runtime.onExternalPtyResize('pty-1', 80, 24)
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Desktop tab gets activated mid-fit; renderer reports real dims.
// These differ from the override (45×20), so they must pass through.
// Desktop tab activated mid-fit reports real dims differing from the override (45×20), so they pass through.
runtime.onExternalPtyResize('pty-1', 200, 60)
// Take back — should land on 200×60, not 80×24.
@@ -683,19 +645,13 @@ describe('mobile subscribe integration', () => {
describe('recordRendererGeometry (pty:reportGeometry IPC)', () => {
it('refreshes subscriber baseline while a mobile-fit override is active', async () => {
// Why: backs the partial-restore-width fix. When phone subscribes to
// a never-desktop-active terminal, the subscriber baseline is the
// PTY spawn default (e.g. 80×24). The renderer's measurement-only
// report (sent when the desktop pane finally measures real geometry)
// must update the baseline so Take Back restores to real dims.
// Why: for a never-desktop-active terminal the baseline is the spawn default; the measurement-only report must update it so Take Back restores real dims.
const { runtime, ptySizes } = createRuntime()
ptySizes.set('pty-1', { cols: 80, rows: 24 })
runtime.onExternalPtyResize('pty-1', 80, 24)
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Override is now in place (phone-fit). Desktop pane becomes visible
// and measures real geometry. The renderer reports it via the
// measurement-only channel.
// Override in place; desktop pane becomes visible and reports real geometry via the measurement-only channel.
runtime.recordRendererGeometry('pty-1', 214, 72)
// Take back — should restore to the reported dims, not 80×24.
@@ -705,10 +661,7 @@ describe('mobile subscribe integration', () => {
})
it('updates lastRendererSizes for a never-subscribed PTY', () => {
// Why: handleMobileSubscribe's previousCols fallback chain reads
// lastRendererSizes. A geometry report fired before the first
// subscribe must populate that cache so the subscriber's baseline
// captures real dims, not the spawn default.
// Why: a geometry report before first subscribe must populate lastRendererSizes so the baseline captures real dims, not the spawn default.
const { runtime } = createRuntime()
runtime.recordRendererGeometry('pty-99', 180, 50)
@@ -725,11 +678,7 @@ describe('mobile subscribe integration', () => {
})
it('bypasses the cascade-suppress window (it is measurement-only)', async () => {
// Why: pty:resize is gated by a 500ms suppress to absorb the safeFit
// cascade after a mode flip. The measurement-only channel must not
// be gated — its whole purpose is to deliver a fresh measurement
// when the renderer detects the pane container has finally settled
// to real geometry, including potentially right after a flip.
// Why: the measurement-only channel must bypass the 500ms cascade-suppress that gates pty:resize, since it can carry a fresh measurement right after a flip.
const { runtime } = createRuntime()
// Arm cascade-suppress.
runtime.setMobileDisplayMode('pty-1', 'desktop')
@@ -792,19 +741,13 @@ describe('mobile subscribe integration', () => {
await vi.advanceTimersByTimeAsync(30_000)
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// The pending timer should have been cancelled by the new subscribe.
// Wait long past what would've been the restore moment.
// The new subscribe should have cancelled the pending restore timer.
await vi.advanceTimersByTimeAsync(120_000)
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
})
it('reclaimTerminalForDesktop with active subscriber resets mode so next subscribe re-fits', async () => {
// Why: desktop "Take back" while the phone is actively driving sets
// mobileDisplayMode='desktop' to drive the layout transition. Without
// resetting it, the next mobile subscribe (e.g. user switches tabs
// back on the phone) sees mode='desktop' and enters passive watch,
// never re-fitting the PTY to phone dims. The phone then renders the
// desktop-dim scrollback echoed back at it. See docs/mobile-fit-hold.md.
// Why: reclaim must reset mode to 'auto', else the next subscribe stays in passive watch and never re-fits to phone dims. See docs/mobile-fit-hold.md.
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
@@ -822,12 +765,7 @@ describe('mobile subscribe integration', () => {
})
it('reclaim with WS disconnect between cycles — held branch restores correctly each time', async () => {
// Why: regression for "subsequent take-back stuck on phone dims" with
// indefinite hold. The realistic flow is:
// subscribe → desktop reclaim → WS disconnect (background app) →
// phone re-subscribes → WS disconnect → desktop reclaim (held branch)
// The held branch must restore to real desktop dims every time, not
// get stuck reading current PTY size (= phone dims).
// Why: regression — under indefinite hold the held-branch reclaim must restore real desktop dims every cycle, not the current (phone) PTY size.
settingsState.mobileAutoRestoreFitMs = null
const { runtime, ptySizes } = createRuntime()
@@ -836,9 +774,7 @@ describe('mobile subscribe integration', () => {
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 })
// Phone WS disconnects (e.g. backgrounding). With indefinite hold,
// PTY stays at phone dims and override persists; subscriber is
// removed from inner.
// WS disconnect under indefinite hold: PTY stays phone-fit, override persists, subscriber removed.
runtime.onClientDisconnected('client-a')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(60_000)
@@ -861,8 +797,7 @@ describe('mobile subscribe integration', () => {
})
it('reclaim → re-subscribe → reclaim cycle works repeatedly', async () => {
// Why: regression for "subsequent take-back doesn't change dims, stuck
// at phone dims". The full ping-pong must work N times, not just once.
// Why: regression — the reclaim/re-subscribe ping-pong must work N times, not just once.
const { runtime, ptySizes } = createRuntime()
for (let i = 0; i < 3; i++) {
@@ -882,11 +817,7 @@ describe('mobile subscribe integration', () => {
})
it('null (indefinite) keeps PTY at phone dims when WS connection closes (onClientDisconnected)', async () => {
// Why: backgrounding the mobile app eventually closes the WebSocket,
// which routes through onClientDisconnected (NOT handleMobileUnsubscribe).
// The disconnect path predates indefinite-hold; without explicit gates
// it would unconditionally restore the PTY to desktop dims and clear
// the override, unmounting the desktop banner.
// Why: WS close routes through onClientDisconnected (not handleMobileUnsubscribe), which must honor indefinite-hold rather than unconditionally restore.
settingsState.mobileAutoRestoreFitMs = null
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
@@ -918,9 +849,7 @@ describe('mobile subscribe integration', () => {
})
it('reclaimTerminalForDesktop prefers fresh desktop geometry for a held PTY', async () => {
// Why: the held override can carry the first phone-fit baseline, but
// desktop can measure newer real geometry while the phone-sized PTY is
// held. Manual restore must honor that fresh desktop measurement.
// Why: manual restore must honor fresh desktop geometry measured while the PTY was phone-held, not the stale baseline.
settingsState.mobileAutoRestoreFitMs = null
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
@@ -955,9 +884,7 @@ describe('mobile subscribe integration', () => {
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// Indefinite hold — no timer scheduled at unsubscribe.
// Switch preference → 60s. The already-held PTY is NOT auto-restored;
// the new value applies to the *next* unsubscribe.
// A finite value applies to the next unsubscribe only; the already-held PTY is not retroactively restored.
runtime.setMobileAutoRestoreFitMs(60_000)
await vi.advanceTimersByTimeAsync(120_000)
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
@@ -1062,8 +989,7 @@ describe('mobile subscribe integration', () => {
priv.notifyMobileSessionTabsChanged('worktree-a')
expect(emits).toHaveLength(1)
// The pending coalesced notify was cancelled by the immediate emit, so no
// duplicate trailing emit fires.
// The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit.
vi.advanceTimersByTime(50)
expect(emits).toHaveLength(1)
@@ -1083,8 +1009,7 @@ describe('mobile subscribe integration', () => {
priv.touchMobileSessionSnapshotsForPty('pty-1')
expect(emits).toHaveLength(0)
// Closing the subscription flushes the pending window so the final state
// still reaches the listener before it is dropped.
// Closing the subscription flushes the pending window so the final state reaches the listener.
unsubscribe()
expect(emits).toHaveLength(1)
})
+33 -123
View File
@@ -144,15 +144,9 @@ export type RuntimeBrowserCommandHost = {
resolveWorktreeSelector(selector: string): Promise<{ id: string }>
getAuthoritativeWindow(): BrowserWindow
getAvailableAuthoritativeWindow(): BrowserWindow | null
// Why: headless serve has no renderer window; browser pages are backed by a
// main-process offscreen backend instead. Null when offscreen browsing is
// unavailable (e.g. environment can't support it), which keeps capability
// reporting honest.
// Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing.
getOffscreenBrowserBackend(): BrowserBackend | null
// Why: the session-tab snapshot is the source of truth for which tab is
// focused. A headless browser create must mark itself active there so paired
// clients keep focus on the new tab instead of the reconcile snapping back to
// a terminal (whose activeTabType the snapshot still reports).
// Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal.
markHeadlessBrowserSessionTabActive?(
worktreeId: string | undefined,
browserPageId: string,
@@ -201,15 +195,10 @@ export class RuntimeBrowserCommands {
return Boolean(guest && !guest.isDestroyed())
}
// Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the
// bridge stores worktreeIds in "repoId::path" format (from the renderer's
// Zustand store). This helper resolves the selector to the store-compatible
// ID so the bridge can filter tabs correctly.
// Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id.
private async resolveBrowserWorktreeId(selector?: string): Promise<string | undefined> {
if (!selector) {
// Why: after app restart, webviews only mount when the browser pane is visible.
// Without --worktree, we still need to activate the view so persisted tabs
// become operable via registerGuest.
// Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest.
const bridge = this.host.getAgentBrowserBridge()
if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, undefined)) {
try {
@@ -222,17 +211,13 @@ export class RuntimeBrowserCommands {
}
const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id
// Why: explicit worktree selectors are user intent, so resolution errors
// must surface instead of silently widening browser routing scope. Only the
// activation step remains best-effort because missing windows during tests
// or startup should not erase the validated worktree target itself.
// Why: explicit selectors are user intent, so resolution errors surface (not silently widen scope); only activation stays best-effort.
const bridge = this.host.getAgentBrowserBridge()
if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) {
try {
await this.ensureBrowserWorktreeActive(worktreeId)
} catch {
// Fall through with the validated worktree id so downstream routing
// still stays scoped to the caller's explicit selector.
// Fall through with the validated worktree id so routing stays scoped to the caller's explicit selector.
}
}
return worktreeId
@@ -257,14 +242,11 @@ export class RuntimeBrowserCommands {
try {
await this.ensureBrowserPageActive(worktreeId, browserPageId)
} catch {
// Fall through with the explicit page target so downstream routing
// returns the existing clear "tab not found" error if wake fails.
// Fall through with the explicit page target; downstream routing surfaces a clear "tab not found" error if wake fails.
}
}
return {
// Why: explicit browserPageId is already a stable tab identity, so we do
// not auto-resolve cwd worktree scoping on top of it. Only honor an
// explicit --worktree when the caller asked for that extra validation.
// Why: an explicit browserPageId is already a stable tab identity, so don't auto-resolve cwd worktree scoping on top of it.
worktreeId,
browserPageId
}
@@ -297,16 +279,11 @@ export class RuntimeBrowserCommands {
return { browserPageId: resolvedPageId, webContents: guest }
}
// Why: browser tabs must become paintable before their webview guest starts
// and registerGuest fires, but automation must not steal the user's visible
// worktree/browser pane. Ask the renderer to background-mount the worktree and
// acquire a hidden automation visibility lease instead of activating the UI.
// Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane.
private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise<void> {
const win = this.host.getAuthoritativeWindow()
win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {})
// Why: hidden/restored browser panes become operable only after the
// renderer's webview mounts and calls registerGuest. Waiting on that IPC is
// both faster and less flaky than sleeping for an arbitrary fixed delay.
// Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep.
await waitForWorktreeTabRegistration(worktreeId)
}
@@ -322,11 +299,7 @@ export class RuntimeBrowserCommands {
await waitForTabRegistration(browserPageId)
}
// Why: agent-browser drives navigation via CDP, which bypasses Electron's
// webview event system. The renderer's did-navigate / page-title-updated
// listeners never fire, leaving the Zustand store (and thus the Orca UI's
// address bar and tab title) stale. Push updates from main → renderer after
// any navigation-causing command so the UI stays in sync.
// Why: CDP navigation bypasses Electron's webview events, so the renderer's did-navigate listeners never fire; push updates to keep the UI in sync.
private notifyRendererNavigation(browserPageId: string, url: string, title: string): void {
try {
const win = this.host.getAuthoritativeWindow()
@@ -336,18 +309,7 @@ export class RuntimeBrowserCommands {
}
}
// Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it
// does not surface the browser pane in the renderer. Without --focus, the
// switch is invisible to the user. With --focus, we send a dedicated IPC
// so the renderer can update its per-worktree active-tab state.
//
// Why this IPC carries `worktreeId` instead of letting the renderer
// dispatch `setActiveWorktree`: multiple agents drive browsers in parallel
// worktrees. A global focus call from agent X would steal the user's
// screen from agent Y's worktree. The renderer-side handler
// (focusBrowserTabInWorktree) updates per-worktree state unconditionally
// and only flips globals when the user is already on the targeted
// worktree. Cross-worktree --focus calls pre-stage silently.
// Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree.
private notifyRendererBrowserPaneFocus(
worktreeId: string | undefined,
browserPageId: string
@@ -374,9 +336,7 @@ export class RuntimeBrowserCommands {
const target = await this.resolveBrowserCommandTarget(params)
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.click(params.element, target.worktreeId, target.browserPageId)
// Why: clicks can trigger navigation (e.g. submitting a form, clicking a link).
// Read the target tab's live URL/title after the click and push to the
// renderer so the UI updates even when automation targeted a non-active page.
// Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page.
const page = bridge.getPageInfo(target.worktreeId, target.browserPageId)
if (page) {
this.notifyRendererNavigation(page.browserPageId, page.url, page.title)
@@ -503,9 +463,7 @@ export class RuntimeBrowserCommands {
}
let active = this.activeScreencastsByPageId.get(browserPageId)
while (active) {
// Why: CDP only allows one Page.startScreencast per page. Treat a new
// subscriber as taking over from a stale/hidden paired client instead of
// surfacing an already-active error in the browser pane.
// Why: CDP allows one Page.startScreencast per page, so a new subscriber takes over a stale/hidden client instead of erroring.
active.stop()
await active.done
stopping = this.stoppingScreencastPageIds.get(browserPageId)
@@ -581,8 +539,7 @@ export class RuntimeBrowserCommands {
if (stoppingPromise || !session) {
return
}
// Why: mobile can unsubscribe and immediately resubscribe on rotation.
// New streams wait for CDP teardown instead of failing with already-active.
// Why: mobile can unsubscribe and instantly resubscribe on rotation; new streams wait for CDP teardown instead of failing already-active.
stoppingPromise = session.done.finally(clearPageGate)
this.stoppingScreencastPageIds.set(browserPageId, stoppingPromise)
}
@@ -668,11 +625,7 @@ export class RuntimeBrowserCommands {
const bridge = this.requireAgentBrowserBridge()
const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId)
if (params.focus) {
// Why: prefer the explicit --worktree the caller passed; fall back to
// the bridge's owning-worktree map for the just-switched tab. The
// owning worktree is what the renderer needs to scope the focus to.
// The renderer NEVER yanks the user across worktrees on this signal
// (see focusBrowserTabInWorktree).
// Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree).
const worktreeId =
target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined
this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId)
@@ -1353,9 +1306,7 @@ export class RuntimeBrowserCommands {
`Browser profile ${params.profileId} was not found`
)
}
// Why: a desktop renderer mounts a <webview>; a headless serve has none and
// backs the page with a main-process offscreen WebContents instead. Both
// register into BrowserManager so all downstream commands resolve uniformly.
// Why: headless serve has no renderer <webview>, so back the page with a main-process offscreen WebContents instead.
if (!this.host.getAvailableAuthoritativeWindow()) {
const offscreen = this.host.getOffscreenBrowserBackend()
if (!offscreen) {
@@ -1378,35 +1329,23 @@ export class RuntimeBrowserCommands {
params.activate
)
// Why: the renderer creates the Zustand tab immediately, but the webview must
// mount and fire dom-ready before registerGuest runs. Waiting here ensures the
// tab is operable by subsequent CLI commands (snapshot, click, etc.).
// If registration doesn't complete within timeout, return the ID anyway — the
// tab exists in the UI but may not be ready for automation commands yet.
// Why: the webview must mount and register before the tab is operable, so wait here (returning the ID anyway on timeout).
if (params.waitForRegistration !== false) {
try {
await waitForTabRegistration(browserPageId)
} catch {
// Tab was created in the renderer but the webview hasn't finished mounting.
// Return success since the tab exists; subsequent commands will fail with a
// clear "tab not available" error if the webview never loads.
// Tab exists in the renderer even if the webview hasn't mounted; subsequent commands surface a clear error if it never loads.
}
}
// Why: newly created tabs should be auto-activated so subsequent commands
// (snapshot, click, goto) target the new tab without requiring an explicit
// tab switch. Without this, the bridge's active tab still points at the
// previously active tab and the new tab shows active: false in tab list.
// Why: auto-activate the new tab so subsequent commands target it without an explicit switch.
const bridge = this.requireAgentBrowserBridge()
const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId)
if (wcId != null) {
bridge.setActiveTab(wcId, worktreeId)
}
// Why: the renderer sets webview.src=url on mount, but agent-browser connects
// via CDP after the webview loads about:blank. Without an explicit goto, the
// page stays blank from agent-browser's perspective. Navigate via the bridge
// so agent-browser's CDP session tracks the correct page state.
// Why: the webview loads about:blank first, so navigate via the bridge to make agent-browser's CDP session track the real URL.
if (url && url !== 'about:blank') {
try {
const result = await bridge.goto(url, worktreeId, browserPageId)
@@ -1441,8 +1380,7 @@ export class RuntimeBrowserCommands {
)
}
// Why: short-circuit no-op switches so the renderer doesn't tear down and
// remount the webview when the tab is already on the requested profile.
// Why: short-circuit no-op switches so the renderer doesn't needlessly tear down and remount the webview.
const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default'
if (currentProfileId === profile.id) {
return {
@@ -1484,17 +1422,11 @@ export class RuntimeBrowserCommands {
})
})
// Why: the renderer destroys the old webview and remounts on the new
// partition. Wait for the re-register so a follow-up tab list
// --show-profile reads the updated sessionProfileId from BrowserManager
// instead of stale data, and so subsequent CLI ops (snapshot, click, etc.)
// hit a guest that's already attached.
// Why: profile change remounts the webview; wait for re-register so follow-up commands see the new profile and an attached guest.
try {
await waitForTabRegistration(browserPageId)
} catch {
// Best-effort: re-register won't fire if the worktree is hidden. The
// store already reflects the new profile; downstream commands retry
// once the pane re-mounts.
// Best-effort: re-register won't fire while the worktree is hidden; downstream commands retry once the pane re-mounts.
}
return {
@@ -1543,8 +1475,7 @@ export class RuntimeBrowserCommands {
profile.id,
profile.partition
)
// Why: parity with browserTabCreate. Wait for the cloned tab's webview to
// register so the returned browserPageId is operable by the next CLI call.
// Why: wait for the cloned tab's webview to register so the returned browserPageId is operable by the next CLI call.
try {
await waitForTabRegistration(created.browserPageId)
} catch {
@@ -1580,8 +1511,7 @@ export class RuntimeBrowserCommands {
async browserProfileDetectBrowsers(): Promise<BrowserDetectProfilesResult> {
return {
// Why: clients only need display metadata for the picker; filesystem
// paths and keychain identifiers stay on the runtime server.
// Why: expose only display metadata; filesystem paths and keychain identifiers stay on the runtime server.
browsers: detectInstalledBrowsers().map((browser) => ({
family: browser.family,
label: browser.label,
@@ -1675,9 +1605,7 @@ export class RuntimeBrowserCommands {
}
tabId = entries[params.index][0]
} else {
// Why: try the bridge first (registered tabs with webviews), then fall back
// to asking the renderer to close its active browser tab (handles cases where
// the webview hasn't mounted yet, e.g. tab was just created).
// Why: try the bridge first; fall back to the renderer for tabs whose webview hasn't mounted yet (e.g. just created).
const tabs = bridge.getRegisteredTabs(worktreeId)
const entries = [...tabs.entries()]
const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId())
@@ -1686,15 +1614,12 @@ export class RuntimeBrowserCommands {
}
}
// Why: headless serve owns its pages via the offscreen backend, with no
// renderer to ask. Destroy the offscreen page directly when that backend is
// the one serving this host (no renderer window).
// Why: headless serve has no renderer to ask, so destroy the offscreen page directly.
const offscreen = this.host.getAvailableAuthoritativeWindow()
? null
: this.host.getOffscreenBrowserBackend()
if (offscreen) {
// Why: for implicit close (no --page/--index) resolve the active page like
// the renderer path does, so we don't report success while closing nothing.
// Why: resolve the active page for implicit close so we don't report success while closing nothing.
const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId)
if (!resolvedTabId) {
return { closed: false }
@@ -1727,11 +1652,7 @@ export class RuntimeBrowserCommands {
}
}
ipcMain.on('browser:tabCloseReply', handler)
// Why: when main cannot resolve a concrete tab id itself (for example if a
// browser workspace exists in the renderer before its guest mounts), the
// renderer still needs the intended worktree scope. Otherwise it falls
// back to the globally active browser tab and can close a tab in the
// wrong worktree.
// Why: pass worktreeId so the renderer scopes the close correctly instead of falling back to the globally active tab in the wrong worktree.
win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId })
})
@@ -1771,10 +1692,7 @@ export class RuntimeBrowserCommands {
return this.enrichBrowserTabInfo(tab)
}
// Why: headless serve path. The offscreen backend registers the page
// synchronously, so there is no webview-mount wait. The page already loaded the
// URL during createTab, so we only sync the bridge's active tab and notify the
// (absent) renderer is skipped — nav state is read from the live WebContents.
// Why: headless serve path — the offscreen backend registers synchronously, so there is no webview-mount wait.
private async createBrowserTabOffscreen(
offscreen: BrowserBackend,
url: string,
@@ -1789,11 +1707,7 @@ export class RuntimeBrowserCommands {
if (bridge && wcId != null) {
bridge.setActiveTab(wcId, worktreeId)
}
// Why: only a user-initiated create (activate:true, e.g. the UI or a mobile
// HTML-link tap) should steal focus by marking the tab active in the session
// snapshot. Background/agent creates (CLI `tab create`, automation) must NOT,
// or they'd yank a connected client/mobile to the new tab. Mirrors the
// renderer path, which forwards `activate` and never force-focuses otherwise.
// Why: only user-initiated creates (activate:true) mark the tab active; agent/background creates must not yank a connected client to it.
if (activate === true) {
this.host.markHeadlessBrowserSessionTabActive?.(worktreeId, browserPageId, targetGroupId)
}
@@ -1836,11 +1750,7 @@ export class RuntimeBrowserCommands {
requestId,
url,
worktreeId,
// Why: leave sessionProfileId/sessionPartition undefined when no explicit
// profile was chosen so the renderer still applies the user's configured
// default-profile inheritance. Only thread the resolved partition when a
// profile is named — sending null here would suppress inheritance and
// force the shared default partition.
// Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance.
sessionProfileId: profileId,
sessionPartition,
activate
+28 -68
View File
@@ -97,8 +97,7 @@ const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150
export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000
const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000
const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0
// Why: runtime files.watch subscriptions are cleaned up through synchronous RPC
// callbacks. Track native Parcel unsubscribe work so app shutdown can drain it.
// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them.
const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
type RuntimeFileWatcherLease = {
suspend(): Promise<void>
@@ -122,9 +121,7 @@ const MOBILE_BINARY_EXTENSIONS = new Set([
'.webp',
'.zip'
])
// Raster image extensions the mobile client can render from a base64 data URI
// via files.readPreview. Mirrors mobile's classifyMobileArtifact image set;
// SVG/PDF are intentionally excluded (RN <Image> can't decode those data URIs).
// Mirror of mobile classifyMobileArtifact's image set; SVG/PDF excluded because RN <Image> can't decode those data URIs.
const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
@@ -203,8 +200,7 @@ function runtimeWatcherReleaseKey(
connectionId: string | undefined,
rootPath: string
): string {
// Why: identical absolute paths are valid on local and multiple SSH hosts;
// destructive teardown must stay scoped to the execution host that owns it.
// Why: identical absolute paths exist on local and multiple SSH hosts; scope teardown to the host that owns it.
return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)])
}
@@ -269,8 +265,7 @@ function registerRuntimeFileWatcherRelease(
})
physicalExitPromise = physicalExit
} else {
// Why: a synchronous close failure retains the native owner so a
// later removal or logical unsubscribe can retry the same handle.
// Why: a synchronous close failure retains the native owner so a later removal or unsubscribe can retry the same handle.
releasePromise = null
}
}
@@ -286,8 +281,7 @@ function registerRuntimeFileWatcherRelease(
if (resumePromise) {
return physicalExitPromise ? Promise.resolve() : resumePromise
}
// Why: a timed-out child still owns native handles until its physical
// exit; restoration must join that owner before starting a replacement.
// Why: a timed-out child still owns native handles until physical exit; join that owner before starting a replacement.
const resumesAfterPhysicalExit = physicalExitPromise !== null
const attempt = Promise.resolve(physicalExitPromise ?? releasePromise)
.then(async () => {
@@ -502,8 +496,7 @@ export class RuntimeFileCommands {
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
// Previewable images open like text (the mobile viewer renders them via
// files.readPreview); other binaries stay unavailable on mobile.
// Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile.
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
@@ -515,15 +508,9 @@ export class RuntimeFileCommands {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: CLI/agents treat opened:true as success. Stat first so missing paths
// fail the RPC instead of creating a ghost editor tab that only errors on read.
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
await this.assertMobileOpenTargetExists(filePath, connectionId)
// Why: the service's internal runtimeId is not a registered runtime env selector
// (those live in orca-environments.json). Passing it caused Unknown environment
// errors on content load for CLI-initiated opens (via files.open from orca cli
// used by agents). Instead pass undefined so the renderer openFile falls back to
// the current activeRuntimeEnvironmentId (or null), matching sidebar opens and
// allowing correct routing for local vs remote envs.
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
this.host.openFile(worktree.id, filePath, relativePath, undefined)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
@@ -596,10 +583,7 @@ export class RuntimeFileCommands {
}
}
// Resolves a path tapped in the mobile terminal (absolute, relative, or ~/…)
// to a worktree-relative path the file RPCs can open, plus existence.
// Relative paths resolve against `cwd` when the caller supplies it, else
// against the worktree root.
// Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root.
async resolveTerminalPath(
worktreeSelector: string,
pathText: string,
@@ -610,8 +594,7 @@ export class RuntimeFileCommands {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
// Why: mobile may attach after OSC7 cwd metadata was emitted; the runtime
// still owns the terminal's latest cwd and can resolve the tap correctly.
// Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap.
const normalizedTerminalHandle =
terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null
const terminalCwd = normalizedTerminalHandle
@@ -630,9 +613,7 @@ export class RuntimeFileCommands {
isDirectory: false
}
// `~/…` is home-relative. The local home is known (os.homedir); the remote
// home is not, so don't guess — a tapped `~/…` on a remote worktree would
// mis-resolve under cwd/worktree-root, so treat it as not-openable instead.
// Why: remote home is unknown (only local os.homedir), so a tapped ~/… on a remote worktree is not-openable, not guessed.
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
if (isTilde && connectionId) {
return empty
@@ -669,9 +650,7 @@ export class RuntimeFileCommands {
}
}
// Why: mobile taps can point at agent-created artifacts outside the
// worktree. Authorize and grant the exact existing path instead of
// widening worktree-relative file RPCs to arbitrary absolute paths.
// Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths.
if (!normalizedTerminalHandle || !terminalCwd) {
return { ...empty, relativePath, absolutePath }
}
@@ -733,9 +712,7 @@ export class RuntimeFileCommands {
: undefined
}
} catch (error) {
// A genuine "not found" → the path simply doesn't exist (report it, not an
// error). Transport/permission/provider failures must surface so a remote
// session doesn't silently report every tapped path as missing.
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
if (
isENOENT(error) ||
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
@@ -746,9 +723,7 @@ export class RuntimeFileCommands {
}
}
// A remote stat failure that means "the file isn't there" vs a transport /
// permission / provider error. The mux drops the ErrnoException `code`, so the
// message is the only signal — match the not-found shapes the relay surfaces.
// The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors).
private static isRemoteNotFoundErrorMessage(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
@@ -797,8 +772,7 @@ export class RuntimeFileCommands {
provider.realpath(absolutePath),
...roots.map((root) => provider.realpath(root).catch(() => root))
])
// Why: SSH reads and writes follow symlinks on the relay. Grant the
// canonical target so a /tmp link cannot escape the temp-artifact boundary.
// Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary.
return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath))
? realArtifactPath
: null
@@ -1153,8 +1127,7 @@ export class RuntimeFileCommands {
grant.absolutePath,
grant.connectionId
)
// Why: relay stat/read/write follow symlinks, so a remote temp artifact
// grant must be re-canonicalized after the terminal process can mutate it.
// Why: relay I/O follows symlinks, so re-canonicalize a remote temp-artifact grant after the process can mutate it.
if (allowedPath !== grant.absolutePath) {
throw new Error('terminal_file_grant_stale')
}
@@ -1209,8 +1182,7 @@ export class RuntimeFileCommands {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
// Why: the RPC layer already threads AbortSignal for local watches; SSH
// must cancel the remote fs.watch request instead of waiting it out.
// Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out.
const close = await provider.watch(target.path, callback, { signal, onTerminalError })
return { unsubscribe: async () => close(), rootPaths: [target.path] }
}
@@ -1224,8 +1196,7 @@ export class RuntimeFileCommands {
const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError)
return { unsubscribe: close, rootPaths: [target.path, rootPath] }
}
// Why: the forked watcher keeps the blocking crawl and native faults out
// of the main/`serve` process (issues #5308 and #8212).
// Why: the forked watcher keeps the blocking crawl and native faults out of the main/`serve` process (issues #5308, #8212).
const dispose = await watchFileExplorerInWatcherProcess(
rootPath,
callback,
@@ -1255,8 +1226,7 @@ export class RuntimeFileCommands {
await Promise.all(Array.from(leases, (lease) => lease.suspend()))
}
if (!connectionId) {
// Why: setup can fail before registerRuntimeFileWatcherRelease publishes
// its callback, while the host still retains an unkillable child owner.
// Why: setup can fail before registerRuntimeFileWatcherRelease publishes its callback while the child owner still lives.
const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore())
await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath)
}
@@ -1587,8 +1557,7 @@ export class RuntimeFileCommands {
preserveSymlink: true
})
await mkdir(dirname(destinationPath), { recursive: true })
// Why: duplicate/copy operations are deconflicted by the caller. COPYFILE_EXCL
// preserves the same no-clobber invariant as the local shell copy IPC.
// Why: COPYFILE_EXCL preserves the no-clobber invariant of the local shell copy IPC (caller already deconflicts names).
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
return { ok: true }
}
@@ -1611,8 +1580,7 @@ export class RuntimeFileCommands {
const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), {
preserveSymlink: true
})
// Why: a non-local runtime has no client OS Trash/Recycling Bin; server-side
// file mutations are permanent and the renderer confirms before calling this.
// Why: a non-local runtime has no client Trash; this delete is permanent, so the renderer confirms before calling.
await rm(targetPath, { recursive: recursive === true, force: true })
return { ok: true }
}
@@ -1827,8 +1795,7 @@ export class RuntimeFileCommands {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const fileStat = await provider.stat(filePath)
// Why: the SSH filesystem API does not expose ranged reads here, so reject
// oversized remote previews instead of streaming a large file just to trim it.
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
throw new Error('file_too_large')
}
@@ -1868,9 +1835,7 @@ function watchWindowsRuntimeFileExplorer(
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
}
// Why: Parcel probes Watchman before the Windows backend and its native
// watcher can abort the headless server process. For remote Windows runtimes,
// a conservative overflow refresh is safer than a process-wide native crash.
// Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead.
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
const onClose = (): void => {
watcher.removeListener('error', onError)
@@ -1884,8 +1849,7 @@ function watchWindowsRuntimeFileExplorer(
}
watcher.removeListener('close', onClose)
watcher.removeListener('error', onError)
// Why: Node closes and nulls FSWatcher's native handle on error without a
// close event; that error is positive physical-exit proof for deletion.
// Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof.
physicalClose.markExited()
if (!disposed) {
try {
@@ -1919,8 +1883,7 @@ function watchWindowsRuntimeFileExplorer(
() => new Error('Windows watcher did not close before deletion deadline')
)
} catch (error) {
// Why: late Windows close still owns native directory handles; expose its
// exact completion so destructive cleanup retains and then clears the root.
// Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root.
throw new WatcherProcessFailure(
error instanceof Error ? error.message : String(error),
'supervisor',
@@ -1961,8 +1924,7 @@ async function isRuntimeDirectoryEntry(
entry: { isDirectory(): boolean; isSymbolicLink(): boolean },
_entryPath: string
): Promise<boolean> {
// Why: runtime-backed file explorer listings are still passive UI reads.
// Do not stat symlink targets here; explicit open/expand can resolve them.
// Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them).
if (entry.isSymbolicLink()) {
void _entryPath
return false
@@ -2013,8 +1975,7 @@ function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): neve
async function readLocalMobileFile(filePath: string, store: Store): Promise<string> {
const authorizedPath = await resolveAuthorizedPath(filePath, store)
const fileStat = await stat(authorizedPath)
// Why: mobile file previews are read-only convenience views; cap the read so
// opening a generated log or bundle cannot block the WebSocket like oversized scrollback.
// Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views).
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
const handle = await open(authorizedPath, 'r')
try {
@@ -2143,8 +2104,7 @@ function normalizeTerminalFileUriAuthorityPath(
if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) {
return normalizeLeadingSlashDrivePath(match[2]!, worktreePath)
}
// Why: a file URI authority names a host. Without a verified host match,
// stripping it could open a same-path local or SSH artifact on the wrong machine.
// Why: without a verified host match, stripping the file-URI authority could open a same-path artifact on the wrong machine.
return pathText
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -94
View File
@@ -20,39 +20,23 @@ export type CoordinatorRuntime = {
handle: string,
options?: { condition?: string; timeoutMs?: number }
): Promise<{ handle: string; condition: string }>
// Why (§3.1): dispatch pre-flight drift check lives on the runtime because
// it needs to resolve a worktree selector, load the repo, and fetch. The
// coordinator only knows about handles + specs; resolving a git worktree
// from this layer would leak transport details here.
// Why (§3.1): lives on the runtime because it must resolve a worktree, load the repo, and fetch — the coordinator only knows handles + specs.
probeWorktreeDrift(worktreeSelector: string): Promise<{
base: string
behind: number
recentSubjects: string[]
} | null>
// Why: optional so lightweight runtime fakes keep compiling; when present,
// dispatch records the remint-stable pane identity of the assignee.
// Why: optional so lightweight runtime fakes keep compiling; when present, dispatch records the assignee's remint-stable pane identity.
getTerminalPaneKey?(handle: string): string | null
// Why: Windows can host native and WSL workers concurrently, so the
// worker pane—not the coordinator process—selects the packaged CLI name.
// Why: Windows can host native and WSL workers at once, so the worker pane (not the coordinator) picks the packaged CLI name.
getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide'
}
// Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20
// in msg_eff3a646110d — lets normal day-of-velocity on active monorepos pass
// while still tripping on the 168-commit harm observed in ORCHESTRATOR_FEEDBACK.md.
// Why (§3.1): 20 lets normal monorepo day-velocity pass but trips the 168-commit harm from ORCHESTRATOR_FEEDBACK.md (chosen in msg_eff3a646110d).
export const DISPATCH_STALE_THRESHOLD = 20
// Why (§3.4): the flag is stashed in the task spec text rather than a DB
// column in v1. The regex is intentionally narrow — only the canonical form
// matches, so typos fail closed (dispatch refuses). Returning the stripped
// spec alongside the boolean keeps this infra line out of the worker's
// `--- TASK ---` block (workers would otherwise read it as an instruction).
//
// Trade-off (§7.9): the regex matches any line of the spec including lines
// inside fenced code blocks. Acceptable v1 limitation — the failure mode is
// "dispatches through when the author didn't intend to," which the preamble
// drift section surfaces to the worker. Skill doc directs authors to place
// the flag as the last line and avoid the literal flag in code examples.
// Why (§3.4): the flag lives in the spec text (no DB column in v1); the regex is narrow so typos fail closed, and stripping keeps the infra line out of the worker's `--- TASK ---` block.
// Trade-off (§7.9): matches any spec line, even inside fenced code — fails open, but the preamble drift section still surfaces staleness to the worker.
const ALLOW_STALE_BASE_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?$/im
const ALLOW_STALE_BASE_STRIP_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?\n?/im
@@ -87,11 +71,7 @@ type CoordinatorState = {
const DEFAULT_POLL_MS = 2000
const MAX_CONCURRENT_DEFAULT = 4
// Why: 10 min matches the preamble's documented heartbeat cadence (5 min) ×
// 2, so a single missed heartbeat is the earliest a dispatch can look stale.
// Keeping this in one place (not a per-call arg) ensures the preamble copy
// and the detector logic stay aligned; moving it to a config would multiply
// the places this constant must be kept in sync.
// Why: 10 min = documented heartbeat cadence (5 min) × 2, so one missed heartbeat is the earliest a dispatch can look stale.
const HUNG_THRESHOLD_MS = 10 * 60 * 1000
export class Coordinator {
@@ -139,9 +119,7 @@ export class Coordinator {
return this.executeLoop(run.id)
}
// Why: the RPC handler creates the coordinator_runs record itself so it can
// return the run ID immediately, then starts the loop in the background.
// This method skips the DB insert and uses the pre-created run ID.
// Why: the RPC handler pre-creates the run record to return the ID immediately, so this method skips the DB insert.
async runFromExistingRun(runId: string): Promise<{
runId: string
status: CoordinatorStatus
@@ -173,8 +151,7 @@ export class Coordinator {
await this.sleep(this.opts.pollIntervalMs)
}
// Why: if stopped early, treat it as failed since tasks are incomplete.
// Also failed if any task explicitly failed.
// Why: an early stop leaves tasks incomplete, so the run counts as failed.
const tasks = this.db.listTasks()
const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed')
const failedTasks = [
@@ -205,11 +182,7 @@ export class Coordinator {
this.stopped = true
}
// Why: the coordinator decomposes the top-level spec into a task DAG.
// For now, tasks must be pre-created before calling run(). The spec is
// stored for context but decomposition is the caller's responsibility —
// AI-driven decomposition belongs in a future phase where the coordinator
// itself is an LLM agent.
// Why: decomposition isn't implemented yet — tasks must be pre-created before run(); AI-driven decomposition is a future phase.
private async decompose(): Promise<void> {
this.state.phase = 'decomposing'
const existing = this.db.listTasks()
@@ -231,11 +204,7 @@ export class Coordinator {
return this.checkConvergence()
}
// Why: emit a single warning per stale dispatch per tick. This intentionally
// does NOT auto-fail the dispatch — the false-positive cost (a slow worker
// producing correct output) is higher than the false-negative cost (a hung
// worker keeps its terminal slot until a human notices). Auto-fail policy
// is a separate decision documented in R6 of DESIGN_DOC_PREAMBLE_FIX.md.
// Why: warn only, never auto-fail — a false positive (slow but correct worker) costs more than a false negative (hung worker holding a slot); see R6 of DESIGN_DOC_PREAMBLE_FIX.md.
private warnStaleDispatches(): void {
const thresholdIso = new Date(Date.now() - HUNG_THRESHOLD_MS).toISOString()
const stale = this.db.getStaleDispatches(thresholdIso)
@@ -317,9 +286,7 @@ export class Coordinator {
return
}
// Why: fail the dispatch so the circuit breaker increments. If under
// the threshold, the task returns to 'pending' and will be re-dispatched
// to a (potentially different) terminal on the next tick.
// Why: fail the dispatch to increment the circuit breaker; under threshold the task returns to 'pending' for re-dispatch next tick.
const updated = this.db.failDispatch(dispatch.id, msg.subject)
if (updated?.status === 'circuit_broken') {
this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`)
@@ -357,22 +324,16 @@ export class Coordinator {
}
private processEscalations(): void {
// Why: escalation processing is handled inline in processMessages via
// handleEscalation. This method exists as a hook for future escalation
// policies (e.g., auto-reassign after N minutes, notify external systems).
// Why: escalations are handled inline via handleEscalation; this stays a hook for future policies (auto-reassign, external notify).
}
private processDecisionGates(): void {
// Why: pending gates that haven't been resolved externally are surfaced
// here. In production, the coordinator UI or a human operator resolves
// gates via orchestration.gateResolve. The coordinator does not auto-
// resolve gates — that would defeat their purpose as approval checkpoints.
// Why: the coordinator never auto-resolves gates (humans do, via orchestration.gateResolve) — that would defeat them as approval checkpoints.
const pendingGates = this.db.listGates({ status: 'pending' })
for (const gate of pendingGates) {
const task = this.db.getTask(gate.task_id)
if (task && task.status !== 'blocked') {
// Why: gate exists but task isn't blocked — inconsistent state.
// Re-block the task to maintain the invariant.
// Why: gate exists but task isn't blocked — re-block to restore the invariant.
this.db.updateTaskStatus(gate.task_id, 'blocked')
}
}
@@ -385,7 +346,6 @@ export class Coordinator {
return
}
// Why: count currently dispatched tasks to enforce concurrency limit.
const dispatched = this.db.listTasks({ status: 'dispatched' })
let slotsAvailable = this.opts.maxConcurrent - dispatched.length
if (slotsAvailable <= 0) {
@@ -394,8 +354,7 @@ export class Coordinator {
const terminals = await this.getAvailableTerminals()
if (terminals.length === 0 && slotsAvailable > 0) {
// Why: no idle terminals exist — create one for the next task.
// Only create one per tick to avoid spawning many terminals at once.
// Why: create at most one terminal per tick to avoid spawning many at once.
try {
const created = await this.runtime.createTerminal(this.opts.worktree, {
title: `Worker: ${readyTasks[0].spec.slice(0, 40)}`
@@ -425,14 +384,7 @@ export class Coordinator {
}
private async dispatchTask(task: TaskRow, targetHandle: string): Promise<void> {
// Why (§3.1): pre-flight drift check BEFORE `createDispatchContext` so a
// refusal does NOT increment failure_count. createDispatchContext carries
// `MAX(failure_count)` forward across contexts (db.ts:301-306), so burning
// the circuit-breaker budget here would convert a recoverable "fetch and
// retry" into a hard `failed` task within ~6s of polling. Silent return
// leaves the task in `ready`; the next `dispatchReadyTasks` tick retries
// naturally, and once the coordinator's worktree has been refreshed
// dispatch proceeds cleanly.
// Why (§3.1): drift check runs before createDispatchContext so a refusal doesn't bump failure_count (carried forward as MAX in db.ts:301-306) and burn the circuit-breaker budget; the task stays `ready` and retries next tick.
const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(task.spec)
let baseDrift: {
base: string
@@ -441,10 +393,7 @@ export class Coordinator {
} | null = null
if (!this.opts.worktree) {
// Why (§7.4): CoordinatorOptions.worktree is optional. When undefined,
// probeWorktreeDrift cannot resolve a selector; log once so operators
// can see the guard did not run for this task and proceed. v2 may
// always resolve a worktree via the coordinator-terminal handle.
// Why (§7.4): worktree is optional; with none we can't probe drift, so log that the guard is inert and proceed.
this.opts.onLog(`stale-base guard inert for ${task.id}: coordinator has no worktree selector`)
} else {
baseDrift = await this.runtime.probeWorktreeDrift(this.opts.worktree).catch((err) => {
@@ -453,9 +402,7 @@ export class Coordinator {
})
if (baseDrift && baseDrift.behind > DISPATCH_STALE_THRESHOLD && !allowStale) {
// Why (§3.1): silent-return, NOT failDispatch (which would burn the
// circuit-breaker budget). The message lists three remediations so
// the operator can recover via any of them.
// Why (§3.1): silent-return, not failDispatch — failing a recoverable stale-base here would burn the circuit-breaker budget.
this.opts.onLog(
`Skipping dispatch of ${task.id}: worktree is ${baseDrift.behind} commits ` +
`behind ${baseDrift.base}. Pull/rebase the worktree, recreate it with ` +
@@ -473,18 +420,11 @@ export class Coordinator {
this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined
)
// Why: agents dispatched by the coordinator must use orca-dev in dev mode
// so they talk to the dev runtime's socket, not production (Section 6.4).
// Why (§3.4): `strippedSpec` drops the `allow-stale-base: true` line so
// the worker's `--- TASK ---` block does not contain the infra flag (which
// the worker would otherwise read as part of its instructions).
// Why: dispatched agents use orca-dev in dev mode to reach the dev runtime's socket, not production (Section 6.4).
const preamble = buildDispatchPreamble({
taskId: task.id,
dispatchId: dispatch.id,
// Why (§3.4, stale-base PR): use `strippedSpec` not `task.spec` so the
// `allow-stale-base: true` line isn't rendered into the worker's
// --- TASK --- block (worker would otherwise treat the infra flag as
// part of its instructions).
// Why (§3.4): strippedSpec drops the allow-stale-base line so the worker doesn't read the infra flag as an instruction.
taskSpec: strippedSpec,
coordinatorHandle: this.opts.coordinatorHandle,
workerHandle: targetHandle,
@@ -492,15 +432,11 @@ export class Coordinator {
...(this.runtime.getTerminalOrchestrationCliCommand
? { cliCommand: this.runtime.getTerminalOrchestrationCliCommand(targetHandle) }
: {}),
// Why (§3.2): drift section fires only when behind > 0. The preamble
// builder gates on this itself; passing the object unconditionally lets
// the coordinator stay dumb about the display rule.
// Why (§3.2): pass baseDrift unconditionally — the preamble builder itself gates the drift section on behind > 0.
...(baseDrift ? { baseDrift } : {})
})
// Why: check if the task was previously blocked by a decision gate that
// has since been resolved. Include the resolution in the preamble so the
// worker knows the decision outcome.
// Why: surface a since-resolved decision gate's outcome to the worker via the preamble.
const gates = this.db.listGates({ taskId: task.id, status: 'resolved' })
let gateContext = ''
if (gates.length > 0) {
@@ -538,11 +474,7 @@ export class Coordinator {
}
}
// Why: exclude the coordinator's own terminal, terminals with active
// dispatches, and disconnected terminals. The dispatch-lock in
// createDispatchContext prevents double-dispatch even if a terminal
// looks available here — this filter is an optimization, not a
// correctness constraint.
// Why: createDispatchContext's dispatch-lock guarantees correctness; this filter is only an optimization to skip busy/disconnected terminals.
return result.terminals
.filter(
(t) =>
@@ -569,8 +501,7 @@ export class Coordinator {
return true
}
// Why: detect stuck state — no ready or dispatched tasks, but some are
// still pending/blocked. This means deps can never be satisfied.
// Why: no active tasks but some blocked → dependencies can never be satisfied (stuck).
const active = tasks.filter(
(t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending'
)
+23 -113
View File
@@ -17,8 +17,7 @@ import type {
import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display'
import { parsePaneKey } from '../../../shared/stable-pane-id'
// Why: leaf UUID is the remint-stable pane identity; the tab half changes on
// break-out. Exact string match covers legacy/unparseable keys.
// Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys.
function isEquivalentPaneKey(a: string, b: string): boolean {
if (a === b) {
return true
@@ -72,8 +71,7 @@ function exposeUtcTimestamp(timestamp: string | null): string | null {
}
function exposeMessageTimestamps(message: MessageRow): MessageRow {
// Why: SQLite stores UTC as a timezone-less space format for internal SQL
// ordering, but RPC/CLI consumers need an explicit offset to interpret it.
// Why: SQLite stores UTC as timezone-less space format for SQL ordering, but RPC/CLI consumers need an explicit offset.
return {
...message,
created_at: exposeUtcTimestamp(message.created_at) ?? message.created_at,
@@ -85,16 +83,7 @@ function exposeMessageListTimestamps(messages: MessageRow[]): MessageRow[] {
return messages.map(exposeMessageTimestamps)
}
// Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at`
// column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so
// push-on-idle can distinguish queued-but-undelivered from user-acknowledged
// messages without touching the `read` bit (check-wait PR). v3 → v4 records
// the terminal that created a task so task-record worktree creation can infer
// the parent workspace even when no dispatch context exists. v4 → v5 adds
// explicit task_title/display_name fields for orchestration worker UI labels.
// v5 → v6 adds pane-identity columns (dispatch_contexts.assignee_pane_key,
// messages.sender_pane_key) so worker_done ownership survives terminal handle
// remints without accepting completions from unrelated panes.
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane-identity columns.
const SCHEMA_VERSION = 6
export class OrchestrationDb {
@@ -205,12 +194,7 @@ export class OrchestrationDb {
this.createUndeliveredInboxIndexIfPossible()
}
// Why: `CREATE TABLE IF NOT EXISTS` is a no-op against an existing on-disk
// DB, so new schema shapes (added columns, widened CHECK constraints) do
// not reach an upgraded user unless we migrate explicitly. The transaction
// guarantees atomicity — a mid-migration crash leaves the DB at the prior
// version because `user_version` is bumped only on success. Idempotent
// re-invocation is a no-op (current >= SCHEMA_VERSION short-circuit).
// Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing).
private migrate(): void {
const current = this.db.pragma('user_version', { simple: true }) as number
if (current >= SCHEMA_VERSION) {
@@ -219,24 +203,14 @@ export class OrchestrationDb {
this.db.exec('BEGIN')
try {
// v1 → v2: add last_heartbeat_at column; widen messages.type CHECK to
// include 'heartbeat'. SQLite cannot ALTER a CHECK constraint, so we
// rebuild the messages table. We also include `delivered_at` in the
// rebuilt schema so DBs migrating from v1 pick up the v3 column in a
// single table-rewrite pass (avoids a second messages-rebuild later).
// v1 → v2: SQLite can't ALTER a CHECK, so rebuild messages to allow 'heartbeat'; fold in v3's delivered_at to skip a second rebuild.
if (current < 2) {
if (!this.hasColumn('dispatch_contexts', 'last_heartbeat_at')) {
this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN last_heartbeat_at TEXT`)
}
if (!this.messagesTypeCheckAllowsHeartbeat()) {
// Why — index list is not optional. createTables() already attached
// idx_messages_id / idx_inbox / idx_messages_undelivered_inbox /
// idx_thread to the old messages table; DROP TABLE removes those
// indexes with it. CREATE INDEX IF NOT EXISTS in createTables() only
// runs on the next process startup, so skipping explicit recreation
// here would leave message lookups full-scanning for the rest of this
// process's lifetime — a silent O(N) perf regression.
// Why: recreate indexes here — DROP TABLE drops them; createTables re-runs only next startup, so skipping full-scans until restart.
this.db.exec(`
CREATE TABLE messages_new (
id TEXT NOT NULL,
@@ -278,12 +252,7 @@ export class OrchestrationDb {
}
}
// v2 → v3: add `delivered_at` column to messages. A DB that reached v2
// via the v1 → v2 rebuild above already has the column (we included
// it in messages_new); this handles DBs that were at v2 before this
// release shipped (preamble PR deployed standalone, then check-wait
// merged). ALTER TABLE is idempotent via the hasColumn probe — a
// duplicate-column error would abort the whole transaction.
// v2 → v3: add messages.delivered_at. hasColumn probe skips DBs that already got it via the v1→v2 rebuild (else a dup-column error aborts the txn).
if (current < 3) {
if (!this.hasColumn('messages', 'delivered_at')) {
this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`)
@@ -335,10 +304,7 @@ export class OrchestrationDb {
`)
}
// Why: sqlite_master stores the original CREATE TABLE SQL including the
// CHECK clause. Inspecting that text is the cheapest reliable way to tell
// whether the pre-rebuild schema already knows about 'heartbeat' without
// needing a dedicated schema_meta row.
// Why: sqlite_master holds the table's CREATE SQL incl. the CHECK — cheapest reliable probe for whether it already allows 'heartbeat'.
private messagesTypeCheckAllowsHeartbeat(): boolean {
const row = this.db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'")
@@ -408,8 +374,7 @@ export class OrchestrationDb {
const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : ''
const body = `Orca rejected this ${message.type}: ${reason}${originalBody}`
const payload = addLifecycleRejectionMarker(message.payload, reason)
// Why: rejected lifecycle signals must remain auditable without reaching
// later read paths as actionable completion or liveness events.
// Why: rejected lifecycle signals stay auditable but must not reach read paths as actionable completion/liveness events.
this.db
.prepare(
`UPDATE messages
@@ -420,12 +385,7 @@ export class OrchestrationDb {
return this.getMessageById(messageId)
}
// Why: push-on-idle delivery must not replay messages that were already
// injected into the PTY. `read` flips only when a check-caller consumes a
// message, so delivered-but-unread rows would otherwise be re-injected on
// every later idle transition (the replay bug). Filter on
// `delivered_at IS NULL` so each row is auto-pushed at most once; explicit
// `check` still sees them via getUnreadMessages.
// Why: delivered_at IS NULL filter — push-on-idle delivers each row at most once; read (set only by check) wouldn't prevent replay.
getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
@@ -469,11 +429,7 @@ export class OrchestrationDb {
this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids)
}
// Why: `delivered_at` is stamped via SQLite's datetime('now') rather than a
// JS ISO string so it uses the same 'YYYY-MM-DD HH:MM:SS' UTC shape as the
// other SQL-default timestamps on this table. A future ORDER BY or
// comparison against created_at relies on this format consistency.
// See design doc §3.2.
// Why: use datetime('now') so delivered_at matches the space-format UTC shape of the table's other timestamps for correct ordering (§3.2).
markAsDelivered(ids: string[]): void {
if (ids.length === 0) {
return
@@ -489,8 +445,7 @@ export class OrchestrationDb {
return
}
const placeholders = ids.map(() => '?').join(',')
// Why: superseded lifecycle messages stay queryable through history but
// must not be consumed or injected after their dispatch has finished.
// Why: superseded lifecycle messages stay in history but must not be consumed or injected after their dispatch finished.
this.db
.prepare(
`UPDATE messages SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now')) WHERE id IN (${placeholders})`
@@ -506,10 +461,7 @@ export class OrchestrationDb {
)
}
// Why: used by `check --all` and `inbox --terminal <handle>` — returns every
// message for a handle regardless of read/delivered state; never touches the
// read bit. Stale-handle safe: if the handle no longer exists, the query
// just returns whatever historical rows remain (§3.3).
// Why: read-only history for a handle — returns every message regardless of read/delivered state, never flips the read bit (§3.3).
getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
@@ -528,12 +480,7 @@ export class OrchestrationDb {
)
}
// Why: thread-scoped read for the `orchestration.ask` wait loop. Filtered
// by `to_handle` so a worker only sees replies addressed to it (not
// messages it sent), and ordered by `sequence` so the first post-ask
// reply is returned first. `afterSequence` lets the caller resume past an
// already-seen marker without re-reading the outbound ask itself. Uses
// the existing idx_thread index (see createTables) — no new index.
// Why: ask wait-loop read — to_handle filter shows only replies to the worker; afterSequence resumes past its own outbound ask.
getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] {
if (afterSequence !== undefined) {
return exposeMessageListTimestamps(
@@ -607,12 +554,7 @@ export class OrchestrationDb {
return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[]
}
// Why: surfaces the active dispatch (assignee handle + dispatch context id)
// alongside each task so coordinators can answer "who is working on task X?"
// from a single query. The LEFT JOIN keeps non-dispatched tasks in the result
// with NULL assignee/dispatch fields so non-dispatched output stays stable.
// The inner subquery picks the most recent active dispatch per task to match
// the semantics of getDispatchContext for dispatched tasks.
// Why: LEFT JOIN keeps non-dispatched tasks (NULL assignee); the MAX(rowid) subquery matches getDispatchContext's most-recent-active-dispatch semantics.
listTasksWithDispatch(filter?: { status?: TaskStatus; ready?: boolean }): (TaskRow & {
assignee_handle: string | null
dispatch_id: string | null
@@ -668,11 +610,7 @@ export class OrchestrationDb {
return this.getTask(id)
}
// Why: when a task completes, check if any pending tasks that depended on it
// now have all deps satisfied. If so, promote them to 'ready'. This is the
// DAG resolution step — it runs synchronously inside the same transaction as
// the status update, so there's no window where a task is completable but its
// children haven't been promoted.
// Why: runs in the status-update transaction, so a completed task never leaves its ready children unpromoted.
private promoteReadyTasks(completedTaskId: string): void {
const candidates = this.db
.prepare("SELECT * FROM tasks WHERE status = 'pending'")
@@ -699,9 +637,7 @@ export class OrchestrationDb {
createDispatchContext(
taskId: string,
assigneeHandle: string,
// Why: the pane key is the remint-stable identity behind the handle;
// recording it at dispatch time lets worker_done ownership survive
// restarts that reissue the handle.
// Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue.
assigneePaneKey?: string
): DispatchContextRow {
const task = this.getTask(taskId)
@@ -712,10 +648,7 @@ export class OrchestrationDb {
throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`)
}
// Why: handle match covers legacy rows without pane keys; when both the
// new assignee and an active row have usable pane keys, also lock on
// equivalent pane identity so a reminted handle cannot open a second
// concurrent dispatch on the same pane.
// Why: lock on pane identity too, so a reminted handle can't open a second concurrent dispatch on the same pane.
const existing = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey)
if (existing) {
@@ -724,8 +657,7 @@ export class OrchestrationDb {
)
}
// Carry forward failure_count from prior contexts so the circuit breaker
// accumulates across retries for the same task.
// Carry forward failure_count so the circuit breaker accumulates across retries for the same task.
const prior = this.db
.prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?')
.get(taskId) as { max_failures: number | null } | undefined
@@ -829,12 +761,7 @@ export class OrchestrationDb {
return active ? this.failDispatch(active.id, error) : undefined
}
// Why: only touch rows that are currently dispatched. A straggler heartbeat
// from a dispatch that already transitioned to `completed` / `failed` /
// `circuit_broken` MUST NOT retroactively bump `last_heartbeat_at`, because
// the stale-dispatch detector is the signal the coordinator uses to know a
// newer dispatch for the same task has hung. Silently no-op'ing keeps the
// zombie-heartbeat race from masking a hung retry (§5.3.4).
// Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4).
recordHeartbeat(dispatchId: string, at: string): void {
this.db
.prepare(
@@ -843,17 +770,7 @@ export class OrchestrationDb {
.run(at, dispatchId)
}
// Why: the query restricts to currently-dispatched contexts AND respects a
// dispatched-at grace. Without `status = 'dispatched'`, every completed /
// failed / circuit_broken row with an old-or-null last_heartbeat_at would
// warn every tick (warning storm). Without `dispatched_at < :threshold`,
// a freshly-dispatched worker would trip the warning during its first
// heartbeat interval (false positive). The stored columns are space-format
// (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so
// raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and
// flags fresh same-date rows as stale (#8452). julianday() parses both
// formats as UTC for a correct numeric comparison; a malformed timestamp
// yields NULL, so that row simply isn't flagged.
// Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452).
getStaleDispatches(thresholdIso: string): DispatchContextRow[] {
return this.db
.prepare(
@@ -883,10 +800,7 @@ export class OrchestrationDb {
)
.run(newStatus, newFailureCount, error, ctxId)
// Why: set the task back to 'ready' (not 'pending') so the coordinator can
// re-dispatch it on the next tick. The task's deps are already satisfied —
// setting it to 'pending' would strand it since promoteReadyTasks only runs
// when a dep completes.
// Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes.
const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready'
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id)
@@ -924,9 +838,7 @@ export class OrchestrationDb {
)
.run(resolution, gateId)
// Why: unblock the task so the coordinator can re-dispatch it with the
// resolution context. Setting to 'ready' rather than the previous status
// because the worker needs to be re-engaged with the decision outcome.
// Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context.
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
@@ -1018,8 +930,6 @@ export class OrchestrationDb {
// ── Queries for Coordinator ──
getIdleTerminals(excludeHandles: string[] = []): string[] {
// Why: returns terminal handles that have no active dispatch, so the
// coordinator knows which terminals are available for new task assignments.
const active = this.db
.prepare(
"SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')"
+11 -41
View File
@@ -1,7 +1,4 @@
// Why: this is the single boundary between raw RPC frames and the OrcaRuntimeService.
// Keeping the schema, handler, and result type attached to one object makes the
// CLI-facing contract greppable and lets the dispatcher verify every payload
// against the same shape the handler consumed during development.
// Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object.
import { ZodError, type ZodType } from 'zod'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { OrcaRuntimeService } from '../orca-runtime'
@@ -51,38 +48,20 @@ export type RpcRequest = {
export type RpcContext = {
runtime: OrcaRuntimeService
// Why: long-poll handlers (e.g. orchestration.check with wait=true) need to
// observe the underlying socket's lifetime so they can release their slot
// and resolve their inner waiters immediately when a client disconnects
// instead of running down the configured timeoutMs. Undefined outside the
// runtime-rpc transport (direct in-process callers don't need it).
// See design doc §3.1 counter-lifecycle.
// Why: lets long-poll handlers release immediately on client disconnect instead of running down timeoutMs. See design doc §3.1.
signal?: AbortSignal
// Why: streaming handlers (notifications/accounts/terminal subscribe)
// register cleanup callbacks against the runtime so reconnects don't leak
// listeners. Keying those cleanups by per-WebSocket connectionId lets the
// server reap all subscriptions for a closing socket, even when other
// sockets for the same deviceToken stay alive (multi-screen mobile).
// Why: per-WebSocket key so the server reaps a closing socket's subscriptions without touching sibling sockets sharing the deviceToken.
connectionId?: string
// Why: shared-control multiplexes many logical streams over one socket. Some
// handlers need the frame id to register cleanup at logical-stream granularity.
// Why: shared-control multiplexes many logical streams over one socket; the frame id lets handlers register cleanup per logical stream.
requestId?: string
// Why: WebSocket RPCs authenticate by mobile device token. State-owning
// handlers use this to clean up when that paired device disconnects.
// Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects.
clientId?: string
// Why: payload windowing/truncation tuned for the constrained mobile payload
// (e.g. native-chat block char cap) must not clip full-screen web/desktop
// clients. Carries the paired device's scope so handlers can gate the diet to
// phones only. Undefined for in-process callers → treat as full-class (no clip).
// Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip).
clientKind?: 'mobile' | 'runtime'
pairing?: PairingRpcContext
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
// responses after the binary terminal cutover. Undefined on Unix/socket
// transports and non-E2EE WebSocket paths.
// Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths.
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
// Why: binary terminal input/resize frames arrive outside JSON-RPC after a
// stream is established. The WebSocket transport owns the connection-scoped
// stream table; handlers register only the stream IDs they created.
// Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created.
registerBinaryStreamHandler?: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
@@ -91,11 +70,7 @@ export type RpcContext = {
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
// Why: defineMethod preserves the inferred param type locally so each handler
// is fully typed, but the erased `RpcMethod` form is what the dispatcher
// actually stores. The erasure lives in one cast inside defineMethod rather
// than in every method file, which is the tradeoff for the variance problem
// of `RpcHandler` being contravariant in its param type.
// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
@@ -124,9 +99,7 @@ export type RpcStreamingHandler<TParams> = (
emit: (result: unknown) => void
) => Promise<void>
// Why: streaming methods emit multiple responses over a long-lived connection.
// The `stream` flag lets the dispatcher distinguish them from one-shot methods
// and route them to the emit-based call path instead of the Promise-based one.
// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path.
export type RpcStreamingMethod = {
readonly name: string
readonly params: ZodType | null
@@ -181,10 +154,7 @@ export class InvalidArgumentError extends Error {
}
}
// Why: zod aggregates all failing fields into `issues`, but the CLI surfaces
// a single string to users. Pick the first issue's message so callers see a
// message that matches the original handler's `Missing terminal handle`-style
// phrasing (each schema supplies that literal message on its own constraint).
// Why: CLI surfaces one string; take the first issue's message, which each schema authors as the user-facing phrasing.
export function formatZodError(error: ZodError): string {
const first = error.issues[0]
return first?.message ?? 'invalid_argument'
+25 -88
View File
@@ -55,8 +55,7 @@ const SendParams = z
priority: z.enum(['normal', 'high', 'urgent']).optional(),
threadId: OptionalString,
payload: OptionalString,
// Why: the sender's pane key is the remint-stable identity used to verify
// worker_done/heartbeat ownership; the from handle stays routing metadata.
// Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata.
senderPaneKey: OptionalString,
devMode: OptionalBoolean
})
@@ -67,8 +66,7 @@ const SendParams = z
) {
return
}
// Why: dispatch lifecycle messages are authority/liveness signals for one
// coordinator. Fanout creates lifecycle mail in unrelated terminals.
// Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals.
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: getLifecycleGroupRecipientError(params.type),
@@ -81,9 +79,7 @@ const CheckParams = z
terminal: OptionalString,
unread: OptionalBoolean,
peek: OptionalBoolean,
// Why: `all` surfaces every message for the handle and skips mark-read.
// Previously the only way to ask for "all" was the hidden RPC trick
// `{unread: false}`. See design doc §3.2 / §3.3.
// Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3).
all: OptionalBoolean,
types: OptionalString,
inject: OptionalBoolean,
@@ -91,9 +87,7 @@ const CheckParams = z
timeoutMs: OptionalFiniteNumber
})
.superRefine((params, ctx) => {
// Why: the CLI encodes --peek as {peek:true, unread:false} so pre-peek
// runtimes degrade to the non-consuming all mode; that pair is one mode,
// not a conflict.
// Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict.
const modes = [
params.unread === true,
params.peek === true,
@@ -115,9 +109,7 @@ const ReplyParams = z.object({
const InboxParams = z.object({
limit: OptionalFiniteNumber,
// Why: filters the inbox listing to a specific handle so coordinators can
// ask "everything for this handle" with either `inbox` or `check --all`
// and get agreeing results. See design doc §3.3.
// Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3).
terminal: OptionalString
})
@@ -133,8 +125,7 @@ const TaskCreateParams = z.object({
const TaskListParams = z.object({
status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(),
ready: OptionalBoolean,
// Why: truncating specs server-side keeps `--brief` cheap over SSH/relay
// transports instead of shipping full specs the CLI then throws away.
// Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away.
brief: OptionalBoolean
})
@@ -158,9 +149,7 @@ const TaskUpdateParams = z.object({
const DispatchParams = z.object({
task: requiredString('Missing --task'),
// Why: --to is only required for real dispatches. When --dry-run is set the
// caller is previewing the preamble and no terminal is targeted, so allow it
// to be absent. The handler enforces presence before any side-effecting work.
// Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work.
to: OptionalString,
from: OptionalString,
inject: OptionalBoolean,
@@ -209,8 +198,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const from = params.from ?? 'unknown'
// Why: older live shells may lack ORCA_PANE_KEY, but the runtime still
// knows the pane behind their resolved handle; persist that authority.
// Why: older shells may lack ORCA_PANE_KEY, but the runtime still knows the pane behind their handle; persist that authority.
const senderPaneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(from) ?? undefined
if (!isGroupAddress(params.to)) {
@@ -226,14 +214,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
payload: params.payload,
senderPaneKey
})
// Why: worker_done/heartbeat sent via `send` must release the dispatch
// lock before waking recipients — a coordinator woken by delivery may
// immediately dispatch to the same terminal, which fails if the lock
// is still held.
// Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held.
if (msg.type === 'worker_done' || msg.type === 'heartbeat') {
const reconciled = reconcileLifecycleMessage(db, msg)
// Why: a suppressed message is already read; waking a `check --wait`
// waiter for it would return an empty result before the deadline.
// Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result.
if (reconciled.action === 'suppressed') {
return { message: msg }
}
@@ -249,9 +233,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
return { message: msg }
}
// Why: group addresses fan out to one message per recipient so each gets
// independent read-tracking, but they share a thread_id so the conversation
// can be correlated (Section 4.5).
// Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5).
const { terminals } = await runtime.listTerminals()
const handles = resolveGroupAddress(params.to, from, terminals, (handle: string) =>
runtime.getAgentStatusForHandle(handle)
@@ -301,10 +283,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
throw new Error(`Invalid --types: ${invalidTypes.join(',')}`)
}
// Why: `all` short-circuits to "everything for the handle, no marking."
// Explicit `unread: false` is also honored for one release as a compat
// shim so in-flight callers don't break (see design doc §5). Otherwise
// today's behavior is preserved: default is unread-only + mark-read.
// Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5).
const showAll = params.all === true || (params.unread === false && params.peek !== true)
const consumeUnread = !showAll && params.peek !== true
@@ -315,9 +294,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
let visibleMessages = messages
if (consumeUnread && messages.length > 0) {
// Why: manual coordinators can consume lifecycle messages before
// the coordinator loop sees them, but unread `check` is still an
// authoritative read path for worker_done/heartbeat.
// Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too.
visibleMessages = messages.map((message) => {
const reconciled = reconcileLifecycleMessage(db, message)
return reconciled.action === 'rejected'
@@ -343,12 +320,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
return result
}
// Why: blocking wait lets coordinators replace sleep+poll loops with a
// single call that resolves when a message arrives or the timeout
// expires. The `signal` plumbed from the RPC transport aborts this
// waiter the moment the client socket closes, so a killed client
// releases its long-poll slot immediately rather than after the full
// timeoutMs. See design doc §3.1 counter-lifecycle.
// Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1).
await runtime.waitForMessage(handle, {
typeFilter: typeFilter as string[] | undefined,
timeoutMs: params.timeoutMs ?? undefined,
@@ -391,10 +363,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
params: InboxParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
// Why: when `terminal` is provided, mirror `check --all` output for that
// handle (same rows in the same sequence order). Stale/unknown handles
// return an empty list instead of erroring, matching the "historical
// rows survive handle deletion" rule in design doc §3.3.
// Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3).
const messages = params.terminal
? db.getAllMessagesForHandle(params.terminal, params.limit)
: db.getInbox(params.limit)
@@ -436,10 +405,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
params: TaskListParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
// Why: listTasksWithDispatch returns the same rows as listTasks plus
// assignee_handle + dispatch_id joined in for tasks that currently have an
// active dispatch. Non-dispatched tasks get NULL for those fields, so
// consumers reading the legacy shape are unaffected.
// Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected.
const joined = db.listTasksWithDispatch({
status: params.status as TaskStatus,
ready: params.ready
@@ -481,13 +447,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
throw new Error(`Task not found: ${params.task}`)
}
// Why: --inject --dry-run lets a coordinator preview the exact preamble
// text that would be injected without mutating task state or touching the
// target terminal. Skips the ready-status check so coordinators can inspect
// the preamble for already-dispatched or blocked tasks too. No dispatch
// context exists yet (that happens after the ready-status check), so
// dispatchId is a placeholder — the real injected preamble gets a real
// ctx.id below.
// Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId.
if (params.dryRun) {
const preamble = buildDispatchPreamble({
taskId: task.id,
@@ -512,10 +472,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`)
}
// Why: dispatching with --inject to a bare shell (zsh/bash) dumps the
// preamble as shell commands, producing gibberish. Check both OSC title
// status and foreground process — Claude Code doesn't emit recognized OSC
// titles on startup, so title-only detection misses freshly spawned agents.
// Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first.
if (params.inject) {
const hasAgent = await runtime.isTerminalRunningAgent(to)
if (!hasAgent) {
@@ -533,10 +490,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
runtime.getTerminalPaneKey(to) ?? undefined
)
// Why: preamble is built here (not before ctx) so `dispatchId` can be
// the real ctx.id — the preamble-hardening PR made dispatchId required
// so heartbeats can attribute liveness to a specific dispatch context,
// not just a task.
// Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task.
const preamble = buildDispatchPreamble({
taskId: task.id,
dispatchId: ctx.id,
@@ -558,9 +512,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
}
}
// Why: returnPreamble is opt-in because the preamble is several hundred
// bytes and most callers don't need it in the response. Exposing it
// supports coordinators that want to log what was injected for auditing.
// Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response.
if (params.returnPreamble) {
return { dispatch: ctx, injected, preamble }
}
@@ -578,10 +530,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
}
const ctx = db.getDispatchContext(params.task)
// Why: --preamble lets callers inspect the exact preamble text that was
// (or would be) injected for this task. The preamble is derived from the
// current task spec, so even after dispatch completes the text can be
// regenerated deterministically.
// Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes.
if (params.preamble) {
const task = db.getTask(params.task)
if (!task) {
@@ -590,9 +539,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
const workerHandle = ctx?.assignee_handle ?? 'worker'
const preamble = buildDispatchPreamble({
taskId: task.id,
// Why: prefer the existing dispatch context's id if we have one
// (so the preview matches what was actually injected); fall back
// to a placeholder when no dispatch has occurred yet.
// Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet.
dispatchId: ctx?.id ?? 'ctx_preview',
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
@@ -611,12 +558,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
name: 'orchestration.ask',
params: AskParams,
handler: async (params, { runtime, signal }) => {
// Why: group addresses have no unambiguous answer semantics (whose
// reply wins? first? consensus?) and the ~60-LOC scope is not the
// place to design that. Rejecting here closes the silent-timeout
// footgun where a worker passing `--to @reviewers` would have the
// decision_gate inserted against a literal string no one subscribes
// to. Workers that need fan-out fall back to `send --type decision_gate`.
// Why: group addresses have no unambiguous answer semantics; rejecting avoids a silent timeout on a decision_gate no one subscribes to.
if (isGroupAddress(params.to)) {
throw new Error(
'ask does not support group addresses; use send --type decision_gate for fan-out questions'
@@ -648,11 +590,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
const deadline = Date.now() + timeoutMs
const afterSequence = outbound.sequence
// Why: loop with a remaining-budget guard so an unrelated distractor
// message that wakes waitForMessage does not cause indefinite iteration.
// waitForMessage is handle-scoped, so we re-query by thread on every
// wake-up to separate "reply in my thread arrived" from "something
// else was delivered to this handle."
// Why: waitForMessage is handle-scoped, so re-query by thread each wake and bound by remaining budget so distractor messages can't loop forever.
while (true) {
const replies = db.getThreadMessagesFor(threadId, from, afterSequence)
if (replies.length > 0) {
@@ -672,8 +610,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
if (remainingMs <= 0) {
return { answer: null, messageId: null, threadId, timedOut: true }
}
// Why: if the asking client disconnects, release the waiter immediately
// while leaving the already-sent decision gate visible to the recipient.
// Why: signal releases the waiter on client disconnect while the already-sent decision gate stays visible to the recipient.
await runtime.waitForMessage(from, { timeoutMs: remainingMs, signal })
}
}
+93 -270
View File
@@ -47,12 +47,10 @@ const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
// Why: remote clients can apply output pressure without pausing runtime PTY ingestion.
const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024
const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
// Why: pending output becomes binary frames, so cap encoded payload bytes, not UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
// Why: keep initial subscribe latency bounded; readiness remains observed after
// this deadline and triggers an in-stream recovery snapshot when it arrives.
// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot.
const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000
let nextTerminalStreamId = 1
@@ -102,9 +100,7 @@ type TerminalMultiplexStream = {
ackInFlightBytes: number
supportsDesktopViewportClaims: boolean
desktopClaimTail: Promise<boolean>
// Why: whether THIS stream registered a remote-desktop width driver, so
// detach only unregisters what it registered — a passive (viewport-less)
// stream sharing a client id must not release another stream's width floor.
// Whether THIS stream registered the width driver, so detach won't release a peer stream's floor.
registeredRemoteDesktopDriver: boolean
remoteDesktopSubscriptionKey: string
pendingRemoteDesktopViewport: { cols: number; rows: number } | null
@@ -116,8 +112,7 @@ type TerminalMultiplexStream = {
pendingOutput: TerminalOutputChunk[]
pendingOutputBytes: number
pendingOutputOverflowed: boolean
// Why: the cols the mobile client last rewrapped to. Re-stream the full
// scrollback only when a reflow actually changes the width.
// Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes.
lastResizeCols: number | undefined
resizeGeneration: number
outputBatcher: ReturnType<typeof createTerminalOutputBatcher>
@@ -126,11 +121,7 @@ type TerminalMultiplexStream = {
unsubscribeFit: () => void
unsubscribeDriver: () => void
unregisterBinaryHandler: () => void
// Why: the exit-wait promise for this slot is only removed from the runtime's
// waiter set on real PTY exit. Aborting this on detach releases it on slot
// unsubscribe, tab-switch re-subscribe, and connection close instead of
// leaking a waiter (and the closed-connection handler context it captures)
// for the life of a never-exiting agent terminal.
// Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter.
exitWaiterAbort: AbortController
}
@@ -224,8 +215,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
return
}
if (!timer) {
// Why: terminal stream output should be coalesced before crossing the
// network. Desktop runtime subscribers need the same burst boundary.
// Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary.
timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS)
if (typeof timer.unref === 'function') {
timer.unref()
@@ -301,8 +291,7 @@ function* iterateTerminalOutputFrameChunks(
}
const finalChunk = takeChunk()
if (shouldDelayFinalSeq) {
// Why: if a future caller reports rawLength that cannot be mapped back to
// UTF-16 offsets, only the final frame can safely carry the high-water mark.
// Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets.
if (finalChunk) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
@@ -327,9 +316,7 @@ function isTerminalInputLockedForClient(
if (client?.type === 'mobile') {
return false
}
// Why: pre-refactor mobile builds did not send client metadata. Desktop
// callers we control now identify as desktop, so keep legacy mobile input
// working without opening the new desktop path.
// Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked).
if (!client) {
return false
}
@@ -340,8 +327,7 @@ async function assertTerminalSendTextWithinLimit(text: string | undefined): Prom
if (!text) {
return
}
// Why: runtime/mobile sends can be paste-sized; validate outside Zod so
// accepted large input yields before terminal runtime dispatch.
// Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch.
if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) {
throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR)
}
@@ -407,8 +393,7 @@ async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder):
try {
await current.commit()
} finally {
// Why: the runtime may yield before the next chunk/suffix, so that write
// needs a fresh reservation if desktop reclaimed the floor meanwhile.
// Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor.
if (claim.current === current) {
claim.current = null
}
@@ -610,8 +595,7 @@ function* iterateTerminalStreamTextPayloads(data: string): Generator<Uint8Array<
}
function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean {
// Why: uncursored terminal reads are bounded previews; limited previews are
// incomplete stream payloads even when the retained buffer was not truncated.
// Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated.
return read.truncated || read.limited === true
}
@@ -730,8 +714,7 @@ async function serializeStableMobileRendererSnapshot(
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
let candidateIndex = 0
for (let attempt = 0; attempt < candidates.length; attempt += 1) {
// Why: stability retries share the six-call snapshot budget. Advance
// toward zero scrollback so the final attempt always has a bounded payload.
// Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload.
candidateIndex = Math.max(candidateIndex, attempt)
const rows = candidates[candidateIndex]
const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId)
@@ -761,12 +744,7 @@ async function serializeStableMobileRendererSnapshot(
return null
}
// Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side
// term.resize(); the restored scrollback snapshot contains HARD newlines from
// the host serialization, so a width change leaves prior output wrapped at the
// old column count. On a real reflow we re-serialize the FULL buffer at the new
// cols and replay it, so scrollback rewraps. Alt-screen TUIs are PTY-repainted
// and have no scrollback, so they keep the geometry-only Resized frame.
// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols.
async function sendMobileResizeRestream(
runtime: OrcaRuntimeService,
ptyId: string,
@@ -774,8 +752,7 @@ async function sendMobileResizeRestream(
event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number },
shouldSend?: () => boolean
): Promise<boolean> {
// Why: only a true PTY geometry reflow rewraps scrollback; mode-change ticks
// that did not change dims would re-send the whole buffer for nothing.
// Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing.
if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) {
return false
}
@@ -810,9 +787,7 @@ async function updateViewportForClient(
client: TerminalViewportClient,
viewport: { cols: number; rows: number },
defaultType: 'mobile' | 'desktop',
// Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so
// it must only refresh a floor the client already owns via its stream (never
// create a leak-prone standalone one). Stream paths that own cleanup register.
// Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'.
registration: 'register' | 'refresh' = 'register',
claim = false
): Promise<{ updated: boolean; applied: boolean }> {
@@ -820,8 +795,7 @@ async function updateViewportForClient(
if (type === 'mobile') {
return runtime.updateMobileViewport(ptyId, client.id, viewport)
}
// Why: stream attachment observes geometry without taking control. Only a
// later activity/claim frame may make this desktop authoritative.
// Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative.
const updated =
registration === 'refresh'
? await runtime.refreshRemoteDesktopViewer(
@@ -884,9 +858,7 @@ const TerminalRead = TerminalHandle.extend({
limit: OptionalFiniteNumber
})
// Why: the legacy handler allowed `title: string | null` and rejected every
// other shape (including `undefined`) with a specific message, which is how
// the CLI signals an intentional "reset". Preserve that distinction exactly.
// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct.
const TerminalRename = TerminalHandle.extend({
title: z.custom<string | null>((value) => value === null || typeof value === 'string', {
message: 'Missing --title (pass empty string or null to reset)'
@@ -898,14 +870,9 @@ const TerminalSend = TerminalHandle.extend({
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input bytes but are not a user
// action that should transfer the shared terminal floor.
// Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients (server falls back to
// the most recent mobile actor when absent). New mobile builds populate
// this so multi-mobile semantics resolve correctly. See
// docs/mobile-presence-lock.md.
// Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md).
client: z
.object({
id: requiredString('Missing client ID'),
@@ -1051,25 +1018,16 @@ const TerminalMultiplexSnapshotRequestFrame = z.object({
})
const TerminalSetDisplayMode = TerminalHandle.extend({
// Why: 'phone' was previously a "stay at phone dims after unsubscribe"
// mode that the toggle UI never produced and nothing in product
// depended on. Removed in favor of two clean modes: 'auto' (mobile
// drives dims while subscribed, desktop restores on last-leave) and
// 'desktop' (no resize, mobile scales the wide canvas down to fit).
// Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit.
mode: z.enum(['auto', 'desktop']),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients.
// Why: identifies the caller for the driver state machine; optional for older mobile clients.
client: z
.object({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
})
.optional(),
// Why: subscribers that registered before viewport was measured have
// a null viewport on their record. Toggling to 'auto' would no-op
// because applyMobileDisplayMode skips phone-fit when viewport is
// missing. Allow the toggle to carry the latest measured viewport so
// the server can store it on the subscriber record before fitting.
// Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing.
viewport: z
.object({
cols: z.number().int().positive(),
@@ -1080,10 +1038,7 @@ const TerminalSetDisplayMode = TerminalHandle.extend({
const TerminalUnsubscribe = z.object({
subscriptionId: requiredString('Missing subscription ID'),
// Why: required when subscribe registered the cleanup under the composite
// key `${terminal}:${clientId}`. If the caller passes a bare-handle
// subscriptionId (older clients), the server reconstructs the composite
// key from `client.id`. See docs/mobile-presence-lock.md.
// Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md).
client: z
.object({
id: requiredString('Missing client ID')
@@ -1091,14 +1046,7 @@ const TerminalUnsubscribe = z.object({
.optional()
})
// Why: in-place viewport update for an existing mobile subscription. Used
// when the keyboard opens/closes on the mobile client and the visible
// terminal area changes — without this, the mobile app had to
// unsubscribe → resubscribe, which (a) flashed the desktop lock banner
// during the brief idle gap and (b) caused the new subscribe to capture
// the already-phone-fitted PTY size as its restore baseline, leaving the
// PTY stuck at phone dims after the phone disconnected. See
// docs/mobile-presence-lock.md.
// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md).
const TerminalUpdateViewport = TerminalHandle.extend({
client: z.object({
id: requiredString('Missing client ID'),
@@ -1111,9 +1059,7 @@ const TerminalUpdateViewport = TerminalHandle.extend({
claim: z.boolean().optional()
})
// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md). `null`
// means Indefinite; finite millisecond values are clamped server-side
// into [5_000, 60min] before persistence.
// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side.
const TerminalSetAutoRestoreFit = z.object({
ms: z.number().nullable()
})
@@ -1212,9 +1158,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
) {
throw new InvalidArgumentError('Invalid terminal query reply')
}
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale (clients recover by re-deriving the handle)
// instead of evaluating driver/lock state against the wrong PTY (#7718).
// Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (
@@ -1255,8 +1199,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
'refresh',
true
)
// Why: a stream-less request has no lifecycle cleanup and cannot safely
// create ownership. Never write at stale geometry if no stream exists.
// Why: a stream-less request can't safely create ownership, so never write at stale geometry.
if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
@@ -1270,8 +1213,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const hasText = typeof params.text === 'string' && params.text.length > 0
const hasSuffix = params.enter === true || params.interrupt === true
if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) {
// Why: guarded sends are two-phase writes. Reject combined payload +
// submit so guard flips cannot create ambiguous partial delivery.
// Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery.
return {
send: {
handle: params.terminal,
@@ -1280,8 +1222,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
}
}
// Why: selected note sends submit with Enter. The runtime must recheck
// permission/no-agent state immediately before accepting the PTY write.
// Why: recheck permission/no-agent state immediately before accepting the PTY write.
const assertSendPreconditions =
params.requireAgentStatus === 'sendable'
? async (ptyId?: string): Promise<void> => {
@@ -1381,11 +1322,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (result.accepted !== true) {
mobileFloorClaim.current?.rollback()
}
// Why: deliberate mobile input is a take-floor action. Drives the
// `* → mobile{clientId}` driver transition so the desktop banner
// remounts (if previously reclaimed) and active phone-fit dims follow
// the most recent actor. Clientless sends are old mobile builds, so use
// the current mobile driver as their compatibility identity.
// Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver.
return { send: result }
}
}),
@@ -1451,9 +1388,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.resizeForClient',
params: TerminalResizeForClient,
handler: async (params, { runtime }) => {
// Why: guarded resolution — a stale handle (pane's PTY replaced under it)
// must fail with terminal_handle_stale instead of resizing the wrong PTY
// (#7718). Clients recover by re-deriving the handle.
// Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
@@ -1515,16 +1450,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.setDisplayMode',
params: TerminalSetDisplayMode,
handler: async (params, { runtime }) => {
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale instead of mutating the wrong PTY's display
// mode/viewport (#7718). Clients recover by re-deriving the handle.
// Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
// Why: late-bind viewport for callers that subscribed in desktop
// mode (no viewport stored). Without this, a 'auto' toggle on a
// viewport-less record skips phone-fit and the user sees no resize.
// Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes.
if (params.viewport && params.client?.id) {
runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport)
}
@@ -1540,9 +1471,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.restoreFit',
params: TerminalHandle,
handler: async (params, { runtime }) => {
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale instead of reclaiming the wrong PTY back to
// desktop dims (#7718). Clients recover by re-deriving the handle.
// Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
@@ -1564,9 +1493,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.updateViewport',
params: TerminalUpdateViewport,
handler: async (params, { runtime }) => {
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale instead of writing viewport state to the wrong
// PTY (#7718). Clients recover by re-deriving the handle.
// Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
@@ -1578,17 +1505,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params.client,
params.viewport,
'mobile',
// Why: one-shot RPC with no disconnect hook — refresh the client's
// existing stream-owned floor only, never create a leak-prone one.
// Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one.
'refresh',
params.claim === true
)
return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq }
}
}),
// Why: desktop remote sessions can have dozens of panes. One streaming RPC
// owns the binary socket and routes terminal slots by streamId while keeping
// legacy subscribe as the compatibility fallback.
// Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback.
defineStreamingMethod({
name: 'terminal.multiplex',
params: TerminalMultiplex,
@@ -1619,10 +1543,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (closed) {
return false
}
// Why: Output `seq` is a UTF-16 high-water the client uses for frame-drop
// gap detection, so a seq-less Output chunk must carry the sentinel 0
// (== "no seq") rather than the cursor value that orders control frames;
// a cursor value would poison the client's expected-seq tracker.
// Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker.
const resolvedSeq =
typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++
const sent = sendBinary(
@@ -1708,11 +1629,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
const size = runtime.getTerminalSize(stream.ptyId)
const displayMode = runtime.getMobileDisplayMode(stream.ptyId)
// Why: dropped ACK-pending output means live frames are no longer a
// complete replay. Send a fresh model snapshot before resuming output.
// Why: truncated marks an unusable snapshot, and clients discard
// those. The recovery snapshot must be applied to cover dropped
// output, so it is only truncated when serialization failed.
// Why: dropped ACK-pending output breaks live replay; send a fresh snapshot before resuming output.
// Why: clients discard truncated snapshots, so mark truncated only when serialization actually failed.
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
@@ -1726,8 +1644,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
data: serialized?.data ?? ''
})
if (serialized && typeof serialized.seq === 'number') {
// Why: retained chunks queued before the snapshot serialized are
// already contained in it; replaying them would duplicate output.
// Why: chunks queued before the snapshot serialized are already in it; replaying them would duplicate output.
const snapshotSeq = serialized.seq
const retained = stream.ackPendingOutput.filter(
(chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq)
@@ -1810,8 +1727,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.unregisterBinaryHandler()
streams.delete(streamId)
flushAllAckPendingOutput()
// Why: release the runtime exit-waiter for this slot (see the field's
// note). The .catch below no-ops because the stream is already deleted.
// Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching.
stream.exitWaiterAbort.abort()
if (stream.isMobile && stream.client?.id) {
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
@@ -1820,9 +1736,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.registeredRemoteDesktopDriver &&
stream.client?.id
) {
// Why: release the remote-desktop width floor so the host can reclaim
// its own width once the last remote viewer leaves — but only if THIS
// stream took it (a passive stream must not release a peer's floor).
// Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor.
runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey)
}
if (emitEnd) {
@@ -1861,8 +1775,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
detachStream(streamId, false, false)
}
// Why: one connection can own many panes backed by the same PTY.
// Remove those floors together so close scans each PTY registry once.
// Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once.
for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) {
void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys)
}
@@ -1898,8 +1811,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
return
}
// Mobile already has the higher-priority floor; a rejected desktop
// viewport claim must never suppress later phone input.
// Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input.
const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail
void inputClaimTail.then((claimed) => {
if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
@@ -1923,8 +1835,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
const cols = viewport.cols
const rows = viewport.rows
// Why: resize registers stream-scoped geometry so detach can release
// it. Older clients lack explicit claims, so Resize remains control.
// Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims.
if (!stream.isMobile && stream.client?.id) {
stream.registeredRemoteDesktopDriver = true
if (stream.buffering) {
@@ -2020,8 +1931,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let size = runtime.getTerminalSize(stream.ptyId)
let displayMode = runtime.getMobileDisplayMode(stream.ptyId)
if (stream.pendingOutputOverflowed) {
// Why: the overflowed tail is newer than the first snapshot. Retry
// so hidden restore receives a current terminal image instead of null.
// Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null.
stream.pendingOutput.splice(0)
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
@@ -2082,9 +1992,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
// Why: a viewer resize that arrived during the snapshot buffering
// window is parked in pendingRemoteDesktopViewport; apply it now or
// it is silently dropped until the viewer's next resize.
// Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize.
if (
!stream.isMobile &&
stream.client?.id &&
@@ -2119,10 +2027,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const isMobile = request.client?.type === 'mobile'
let leaf: { ptyId: string | null } | null
try {
// Why: guarded resolution — binding the output stream to whatever
// PTY now occupies a stale handle's pane silently mirrors the wrong
// terminal after a reconnect (#7718). terminal_handle_stale lets the
// client re-derive the handle from the current session snapshot.
// Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718).
leaf = runtime.resolveLiveLeafForHandle(request.terminal)
} catch {
sendStreamError(request.streamId, 'terminal_handle_stale')
@@ -2130,8 +2035,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
if (!leaf?.ptyId && request.client) {
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
// Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree.
runtime.requestRendererTerminalTabMount(request.terminal)
const waitController = new AbortController()
const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set()
@@ -2140,8 +2044,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (signal?.aborted) {
waitController.abort()
}
// Why: the live slot handler does not exist until the PTY attaches;
// retain cancellation ownership while the pane is still pending.
// Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending.
const unregisterPendingHandler = registerBinaryStreamHandler(
request.streamId,
(frame) => {
@@ -2180,12 +2083,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
// Why: a competing subscribe for the same streamId can fully register
// while this one awaited the PTY id above. Overwriting it in
// `streams` would orphan its data/view-subscriber registrations — a
// leaked view subscriber permanently silences the model query
// responder (terminal-query-authority.md). Detach it so every
// registration stays release-balanced.
// Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md).
detachStream(request.streamId, false)
const ptyId = leaf.ptyId
@@ -2200,10 +2098,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1,
desktopClaimTail: Promise.resolve(true),
registeredRemoteDesktopDriver: false,
// Why: streamId is client-local, so two remote connections can both
// use stream 1 for the same PTY. Scope the width-floor key by
// connectionId (guaranteed present above) so they can't
// overwrite/release each other's floor.
// Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor.
remoteDesktopSubscriptionKey: `multiplex:${connectionId}:${request.streamId}`,
pendingRemoteDesktopViewport: null,
buffering: true,
@@ -2252,10 +2147,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
stream.outputBatcher.push(data, meta)
})
// Why: a multiplexed stream feeds a remote xterm view that answers
// terminal queries with view authority; the main model responder
// yields while it is attached (terminal-query-authority.md).
// Wrapped into unsubscribeData so every detach path releases it.
// Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md).
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
stream.unsubscribeData = () => {
releaseViewSubscriber()
@@ -2265,8 +2157,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (isMobile && request.client?.id) {
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
} else if (request.client?.id && request.viewport) {
// Why: subscribe records this stream's geometry and cleanup key,
// but does not claim ownership. Activity frames claim later.
// Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later.
stream.registeredRemoteDesktopDriver = true
stream.pendingRemoteDesktopViewport = request.viewport
}
@@ -2348,8 +2239,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
})
// Why: baseline for resize re-stream gating; the client already
// rewrapped to these cols via the initial snapshot replay.
// Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay.
stream.lastResizeCols = serialized?.cols ?? size?.cols
stream.buffering = false
const pendingOutput = stream.pendingOutput.splice(0)
@@ -2411,9 +2301,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols
if (widthChanged) {
stream.lastResizeCols = event.cols
// Why: re-serialize+replay the full scrollback at the new cols so
// restored hard-wrapped lines rewrap; the await means later live
// output still flows on this stream after the snapshot lands.
// Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands.
void sendMobileResizeRestream(
runtime,
ptyId,
@@ -2436,8 +2324,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
sendResizedFrame(stream, event)
}
})
// Why: if re-stream serialization/runtime throws, still emit the
// geometry-only Resized frame so the client never misses the resize.
// Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize.
.catch(() => {
if (
closed ||
@@ -2452,8 +2339,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
sendResizedFrame(stream, event)
})
// Install the resize listener before draining the parked viewport;
// applyLayout emits synchronously and the stream must observe it.
// Install the resize listener before draining the parked viewport, since applyLayout emits synchronously.
if (
!stream.isMobile &&
stream.client?.id &&
@@ -2489,10 +2375,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
})
} catch (error) {
// Why the ownership check: a newer subscribe may own this streamId
// now (it detached and released this stream on arrival). Detaching
// or erroring the slot here would tear down the successor's live
// registrations instead of this stream's.
// Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations.
if (streams.get(request.streamId) !== stream) {
return
}
@@ -2518,9 +2401,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
await multiplexClosed
}
}),
// Why: terminal.subscribe streams live terminal output over WebSocket.
// It sends initial scrollback, then live data chunks as they arrive.
// Mobile clients pass client+viewport params for server-side auto-fit.
// terminal.subscribe: streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit.
defineStreamingMethod({
name: 'terminal.subscribe',
params: TerminalSubscribe,
@@ -2536,19 +2417,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
: 0
let rendererMountRequestedBeforePty = false
const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary)
// Why: a closed stream must not allocate listeners, mobile-fit state, or
// a hidden renderer surface that no client remains to consume.
// Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume.
if (signal?.aborted) {
return
}
// Why: the left pane's PTY spawns asynchronously after the tab is created.
// Clients that subscribe before the PTY is ready would get a bare
// scrollback+end with no live stream or phone-fit. Wait for the PTY so
// the subscribe can proceed normally.
// Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end.
if (!leaf?.ptyId && params.client) {
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
// Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree.
rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal)
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
@@ -2581,8 +2457,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const clientId = params.client?.id
const mobileInputLeaseOnly =
isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)
// Why: the initial mount/PTY wait and phone-fit can both emit a redraw
// that creates suffix-only state, so preserve the pre-mount absence signal.
// Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first.
const missingHeadlessStateBeforeMobileFit =
isMobile &&
(rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false)
@@ -2599,8 +2474,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
resolveStream = resolve
})
const subscriptionId = `${params.terminal}:${clientId}`
// Why: chat needs the input-floor acknowledgement without registering
// a view subscriber or transporting duplicate PTY output.
// Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -2618,8 +2492,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
try {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
if (closed || signal?.aborted) {
// Why: a disconnect can win the awaited subscribe and otherwise
// resurrect mobile presence after cleanup already released it.
// Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it.
runtime.handleMobileUnsubscribe(ptyId, clientId)
if (!closed) {
runtime.cleanupSubscription(subscriptionId)
@@ -2634,13 +2507,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
return
}
// Why: only unregister the width floor this subscription took (see the
// multiplex stream's registeredRemoteDesktopDriver note).
// Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note).
let registeredRemoteDesktopDriver = false
if (!useBinaryStream) {
// Why: desktop can have both a hidden automation watcher and a visible
// pane subscribed to the same terminal. Key by client when provided so
// one stream cannot evict the other.
// Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}`
let closed = false
@@ -2651,8 +2521,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
// Why: register before viewport/snapshot awaits so a socket close cannot
// orphan either the stream listeners or its remote-desktop width floor.
// Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -2714,11 +2583,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
outputBatcher?.push(data)
})
// Why: this legacy JSON stream can feed a live xterm view too
// (older web/desktop subscribers), so it conservatively registers
// as a remote view subscriber. For read-only watchers the cost is
// a withheld model reply — the pre-Phase-5 status quo — which is
// strictly safer than a double reply under a view consumer.
// Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
@@ -2738,8 +2603,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
rows: event.rows
})
})
// Why: bind the exit-waiter to the connection dispatch signal so it is
// removed on socket close/error instead of leaking until real exit.
// Why: bind the exit-waiter to the connection signal so socket close/error removes it instead of leaking until real exit.
void runtime
.waitForTerminal(params.terminal, { condition: 'exit', signal })
.then(() => runtime.cleanupSubscription(subscriptionId))
@@ -2758,8 +2622,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let closed = false
let buffering = true
let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null
// Why: the cols the mobile client last rewrapped to; gate the
// resize re-stream so it only fires on an actual width change.
// Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change.
let lastResizeCols: number | undefined
let resizeGeneration = 0
let pendingOutput: TerminalOutputChunk[] = []
@@ -2781,9 +2644,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
// Why: register cleanup before any mobile-fit or snapshot await. A phone
// can disconnect mid-subscribe; cleanup must still remove mobile
// presence. Client-scoped ids also allow parallel desktop subscribers.
// Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
runtime.registerSubscriptionCleanup(
subscriptionId,
@@ -2806,8 +2667,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
},
connectionId
)
// Why: bind the exit-waiter to the connection dispatch signal so it is
// removed on socket close/error instead of leaking until real exit.
// Why: bind the exit-waiter to the connection signal so socket close/error removes it instead of leaking until real exit.
void runtime
.waitForTerminal(params.terminal, { condition: 'exit', signal })
.then(() => runtime.cleanupSubscription(subscriptionId))
@@ -2986,8 +2846,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
outputBatcher?.push(data, meta)
})
// Why: live bytes must be captured before mobile fit awaits. Registering
// mobile presence first would suppress main while no view held the query.
// Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
@@ -2998,8 +2857,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
} else if (clientId && params.viewport) {
// Why: legacy subscribe records geometry without taking ownership;
// only an explicit activity/claim frame may suppress the host.
// Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host.
registeredRemoteDesktopDriver = true
pendingRemoteDesktopViewport = params.viewport
}
@@ -3012,18 +2870,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
// Why: missing model statenot snapshot text—is the signal that this
// PTY may never have attached; avoid remounting legitimate blank panes.
// A renderer-sourced snapshot also proves the exact pane is already
// attached, so waiting for a fresh mount generation would only stall.
// Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount.
const mountRequested =
missingHeadlessStateBeforeMobileFit &&
serialized?.source !== 'renderer' &&
(rendererMountRequestedBeforePty ||
runtime.requestRendererTerminalTabMount(params.terminal))
if (missingHeadlessStateBeforeMobileFit && mountRequested) {
// Why: an idle legacy PTY emits no later byte; a fresh settle proves
// this exact remount completed before we replay its restored screen.
// Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen.
const mountWaitController = new AbortController()
const abortMountWait = (): void => mountWaitController.abort()
abortRendererMountWait = abortMountWait
@@ -3084,8 +2938,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
)
}
} else {
// Why: a renderer can settle after the bounded initial response.
// Keep observing it so an idle PTY still self-heals without bytes.
// Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes.
lateRendererReadyPromise = rendererReadyPromise
}
}
@@ -3108,16 +2961,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: emit the current layout seq with the initial scrollback so
// the mobile client's stale-event filter knows the high-water mark.
// Undefined when the PTY has never transitioned (filter is fail-open).
// See docs/mobile-terminal-layout-state-machine.md.
// Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md.
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
// Why: recovery snapshots advance output coverage past the initial
// snapshot seq; query replay and boundary trims must track the seq
// that actually covered the buffered chunks or a query absorbed by a
// recovery snapshot gets zero replies.
// Why: track the seq that actually covered the buffered chunks (recovery snapshots advance it) or an absorbed query gets zero replies.
let snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
@@ -3154,12 +3001,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
scrollbackRows: serialized?.scrollbackRows,
truncatedByByteBudget: serialized?.truncatedByByteBudget === true
})
// Why: baseline for resize re-stream gating; the client already
// rewrapped to these cols via the initial snapshot replay.
// Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay.
lastResizeCols = serialized?.cols ?? size?.cols
let recoveryAttempts = 0
// Why: if the bounded pre-subscribe tail overflowed, only a fresh
// model snapshot can cover the dropped middle without replay gaps.
// Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps.
while (pendingOutputOverflowed && recoveryAttempts < 2) {
pendingOutputOverflowed = false
recoveryAttempts += 1
@@ -3170,18 +3015,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!recovery) {
break
}
// Why: without an output seq (renderer-source fallback) covered
// chunks cannot be trimmed exactly, and the renderer view may lag
// the queued chunks under backpressure. Keep the bounded replay
// instead of applying an unverifiable snapshot.
// Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot.
if (typeof recovery.seq !== 'number') {
break
}
// Why: shipped mobile clients drop a second scrollback snapshot for
// an initialized handle but apply a resized snapshot inline by
// re-initializing xterm with fresh scrollback. Omit seq on the wire
// so the client's layout-seq staleness filter is not polluted with
// output-byte sequences.
// Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter.
const recoveryStats = sendSnapshotFrames(sendFrame, {
kind: 'resized',
cols: recovery.cols,
@@ -3220,9 +3058,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
.map((query) => query.data)
.join('')
if (queryReplayData) {
// Why: serialized snapshots omit control queries, yet their output seq
// can trim the live chunk. Replay only the query after snapshot so the
// mobile xterm answers once while ordinary output stays deduplicated.
// Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once.
outputBatcher.push(queryReplayData)
}
if (!initialOutputOverflowed) {
@@ -3269,14 +3105,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!recovery?.data.length) {
return
}
// Why: late recovery has no buffered-output gate. Only an exact
// renderer high-water may reset mobile without erasing live bytes.
// Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes.
if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) {
return
}
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery)
// Why: shipped mobile clients apply resized snapshots in place,
// allowing a blank initialized xterm to recover without resubscribe.
// Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe.
const recoveryStats = sendSnapshotFrames(sendFrame, {
kind: 'resized',
cols: recovery.cols,
@@ -3324,11 +3158,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
outputBatcher?.flush()
const eventGeneration = resizeGeneration + 1
resizeGeneration = eventGeneration
// Why: a width reflow rewraps scrollback. xterm can only re-wrap
// soft-wrapped lines, so a geometry-only Resized frame leaves the
// hard-wrapped restored snapshot at the old cols. Re-serialize and
// replay the full buffer at the new width instead. Non-mobile and
// alt-screen TUIs keep the geometry-only frame + TUI redraw.
// Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback.
const widthChanged = isMobile && event.cols !== lastResizeCols
if (widthChanged) {
lastResizeCols = event.cols
@@ -3347,8 +3177,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
sendResizedFrame(event)
}
})
// Why: if re-stream serialization/runtime throws, still emit the
// geometry-only Resized frame so the client never misses the resize.
// Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize.
.catch(() => {
if (closed || resizeGeneration !== eventGeneration) {
return
@@ -3360,8 +3189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
sendResizedFrame(event)
})
// Install the resize listener before draining the parked viewport;
// applyLayout emits synchronously and the stream must observe it.
// Install the resize listener before draining the parked viewport, since applyLayout emits synchronously.
if (
clientId &&
params.client &&
@@ -3410,12 +3238,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
name: 'terminal.unsubscribe',
params: TerminalUnsubscribe,
handler: async (params, { runtime }) => {
// Why: the subscribe handler now registers cleanup under a composite
// key `${terminal}:${clientId}`. New mobile builds emit the composite
// key directly. Older builds emit a bare-handle subscriptionId; if
// they additionally provide `client.id`, reconstruct the composite
// key server-side. We always try the as-sent value first, then fall
// back to the reconstructed composite, so both wire formats work.
// Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key.
runtime.cleanupSubscription(params.subscriptionId)
if (params.client && !params.subscriptionId.includes(':')) {
runtime.cleanupSubscription(`${params.subscriptionId}:${params.client.id}`)
+29 -112
View File
@@ -1,8 +1,4 @@
// Why: the WebSocket transport enables mobile clients to connect to the Orca
// runtime over the local network. When TLS cert/key are provided it uses wss://
// to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device
// tokens (validated by the message handler in OrcaRuntimeRpcServer) provide auth
// regardless of transport encryption.
// WebSocket transport letting mobile clients reach the Orca runtime over LAN (wss:// with TLS, else ws://); auth is per-device tokens, independent of transport encryption.
import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https'
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
import { WebSocketServer, type WebSocket } from 'ws'
@@ -10,14 +6,9 @@ import type { RpcTransport } from './transport'
import { createStaticWebClientHandler } from './static-web-client-handler'
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
// Why: desktop remote-host clients can legitimately hold many concurrent
// streams (session tabs, terminals, file watches, browser streams). Keep the
// cap high enough that leaked/stale streams do not starve short control RPCs.
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
const MAX_WS_CONNECTIONS = 128
// Why: hard-bound this listener's descriptor use above the WS-upgrade cap.
// Node accepts then drops sockets beyond maxConnections, so raw/pre-upgrade
// clients cannot grow without bound while the existing WS budget remains
// available to legitimate long-lived streams.
// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound.
const MAX_TCP_CONNECTIONS = MAX_WS_CONNECTIONS * 2
const PRE_AUTH_TIMEOUT_MS = 10_000
type WebSocketMessagePayload = string | Uint8Array<ArrayBufferLike>
@@ -29,16 +20,7 @@ type WebSocketMessageHandler = {
): void
}['bivarianceHack']
// Why: mobile clients (iOS/Android) regularly background-suspend their
// sockets without the OS sending a TCP FIN/RST, leaving the server with
// half-open WebSockets that count toward MAX_WS_CONNECTIONS. Without this
// heartbeat the only thing that ever reaps them is the OS's TCP keepalive
// (default macOS: ~2 hours idle + 11 min of probes), which is the
// "connection randomly turns green again after a long delay" symptom.
// Pinging every 15s and terminating any client that hasn't pong'd by the
// next tick collapses that worst case to ~30s. RN/browser WebSocket
// runtimes auto-respond to server pings with pongs at the protocol layer,
// so this works for any client that speaks RFC 6455.
// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~30s (clients auto-pong per RFC 6455).
const HEARTBEAT_INTERVAL_MS = 15_000
export type WebSocketTransportOptions = {
@@ -50,19 +32,11 @@ export type WebSocketTransportOptions = {
heartbeatIntervalMs?: number
// Why: test-only override. Production uses PRE_AUTH_TIMEOUT_MS.
preAuthTimeoutMs?: number
// Why: the pairing server can also serve the browser client, so users do
// not need a second dev/static server once the web bundle is built.
// Why: the pairing server can also serve the browser client, avoiding a second static server.
staticRoot?: string
// Why: paired mobile devices store the full ws://ip:port endpoint. Once a
// fallback port has been assigned and persisted, devices paired while it was
// active point at it, so it must be bound FIRST on later launches — binding
// the (now free) preferred port instead would strand those pairings
// (STA-1511). Callers pass the previously assigned fallback port here.
// Why: devices paired while the fallback port was active point at it, so it must bind first on later launches or those pairings strand (STA-1511).
fallbackPort?: number
// Why: `orca serve --port <P>` clients dial the pinned port. Prefer that port
// first (fallback second) so a stale mobile-ws-fallback-port.json cannot
// silently steal the pin (issue #8535). Default auto/desktop keeps
// fallback-first for STA-1511 pairing stability.
// Why: serve --port clients dial the pinned port; prefer it first so a stale fallback can't steal the pin (issue #8535). Default keeps fallback-first (STA-1511).
preferPinnedPort?: boolean
}
@@ -79,16 +53,13 @@ export class WebSocketTransport implements RpcTransport {
private httpServer: HttpsServer | HttpServer | null = null
private wss: WebSocketServer | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
// Why: tracks whether each socket has pong'd since the last heartbeat
// sweep. A socket missing from the set when the next sweep fires is
// assumed dead and terminated.
// Why: a socket absent from this set at the next heartbeat sweep is presumed dead and terminated.
private wsAlive = new WeakSet<WebSocket>()
private messageHandler: WebSocketMessageHandler | null = null
private connectionCloseHandler:
| ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void)
| null = null
// Why: maps each WebSocket to the clientId (deviceToken) that authenticated it,
// so ws.on('close') can notify the runtime which mobile client disconnected.
// Why: maps each socket to its authenticated clientId so close can report which device disconnected.
private wsClientIds = new Map<WebSocket, string>()
private preAuthTimers = new WeakMap<WebSocket, ReturnType<typeof setTimeout>>()
@@ -118,12 +89,7 @@ export class WebSocketTransport implements RpcTransport {
this.messageHandler = handler
}
// Why: handlers receive the closing `ws` so per-connection state can be
// targeted exactly (one paired device may hold multiple concurrent sockets,
// e.g. host screen + accounts screen). `hasOtherConnections` tells the
// runtime whether other sockets for the same deviceToken are still alive,
// so client-scoped teardown (mobile-fit overrides, etc.) only fires on the
// last disconnect.
// Why: pass the closing `ws` and whether other sockets share its deviceToken, so client-scoped teardown fires only on the last disconnect.
onConnectionClose(
handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void
): void {
@@ -140,16 +106,13 @@ export class WebSocketTransport implements RpcTransport {
.filter(([, candidateClientId]) => candidateClientId === clientId)
.map(([ws]) => ws)
for (const ws of sockets) {
// Why: revocation is a security boundary; terminate skips the close
// handshake so a revoked mobile stream stops immediately.
// Why: revocation is a security boundary; terminate() skips the handshake so a revoked stream stops immediately.
ws.terminate()
}
return sockets.length
}
// Why: when port 0 is passed the OS assigns a random available port. The
// runtime metadata and mobile QR code need the real port, so callers read
// it here after start() resolves.
// Why: with port 0 the OS assigns a random port; callers read the real bound port here for metadata and the mobile QR.
get resolvedPort(): number {
const addr = this.httpServer?.address()
if (addr && typeof addr === 'object') {
@@ -163,16 +126,7 @@ export class WebSocketTransport implements RpcTransport {
return
}
// Why: default order binds a persisted fallback FIRST — devices paired
// while it was active store ws://ip:<fallback> and would be stranded if a
// later launch grabbed the (now free) preferred port instead (STA-1511).
// Explicit serve --port flips the order so the pinned port wins when free
// (issue #8535); fallback remains a secondary candidate for EADDRINUSE.
// Without a persisted fallback only the preferred port is tried. On
// EADDRINUSE each candidate falls through to the next, ending at port 0
// (OS-assigned) so mobile pairing still works when everything is taken.
// The QR code reads resolvedPort after start, so it always advertises the
// port that actually bound.
// Why: bind a persisted fallback first so devices paired to it aren't stranded (STA-1511); serve --port flips to pinned-first (issue #8535); on failure each candidate falls through to OS-assigned port 0.
const persistedFallbackPort =
this.fallbackPort !== undefined && this.fallbackPort !== 0 && this.fallbackPort !== this.port
? this.fallbackPort
@@ -188,12 +142,7 @@ export class WebSocketTransport implements RpcTransport {
await this.tryListen(port)
return
} catch (error: unknown) {
// Why: a persisted fallback can become unbindable for reasons beyond
// EADDRINUSE (e.g. Windows reserves dynamic-range ports for Hyper-V
// after a reboot → EACCES). Any fallback failure must degrade to the
// next candidate — aborting would disable the transport every launch
// while the store still names that port. Only preferred-port failures
// other than EADDRINUSE are fatal.
// Why: any fallback-port failure must degrade to the next candidate (Windows can reserve the port → EACCES, not just EADDRINUSE); only non-EADDRINUSE preferred-port failures are fatal.
if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) {
throw error
}
@@ -215,9 +164,7 @@ export class WebSocketTransport implements RpcTransport {
: createHttpServer(requestListener)
}
// Why: the WebSocketServer is attached only after listen succeeds. If we
// attached it before, the WSS would re-emit the EADDRINUSE error from the
// httpServer as an uncatchable exception, preventing the fallback from working.
// Why: attach the WSS only after listen succeeds; earlier it re-emits httpServer's EADDRINUSE as an uncatchable exception and breaks the fallback.
private async tryListen(port: number): Promise<void> {
const httpServer = this.createHttpServer()
@@ -229,8 +176,7 @@ export class WebSocketTransport implements RpcTransport {
})
})
// Why: the WS cap applies only after upgrade. A separate TCP cap prevents
// raw and pre-upgrade sockets from consuming an unbounded descriptor budget.
// Why: the WS cap applies only post-upgrade; a separate TCP cap bounds raw/pre-upgrade descriptor use.
httpServer.maxConnections = MAX_TCP_CONNECTIONS
const wss = new WebSocketServer({
@@ -251,26 +197,16 @@ export class WebSocketTransport implements RpcTransport {
this.startHeartbeat()
}
// Why: over the WS-upgrade cap, request a graceful 1013 close but force the
// socket down shortly after. A backgrounded/half-open phone may never ack the
// close frame, so a bare ws.close() can retain the descriptor until the next
// heartbeat. Under a reconnect flood, shortening that window bounds the
// number of rejected sockets that can accumulate behind the WS cap. The
// 'error' listener prevents a reset while closing from becoming unhandled.
// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing.
private rejectOverCapacity(ws: WebSocket): void {
ws.on('error', () => {})
ws.close(1013, 'Maximum connections reached')
// Why: give the 1013 close a brief window, then hard-terminate so the
// descriptor is freed even if the client never acks the close frame.
const terminateTimer = setTimeout(() => ws.terminate(), 1_000)
terminateTimer.unref?.()
ws.once('close', () => clearTimeout(terminateTimer))
}
// Why: ping every live socket on a fixed cadence and terminate any that
// didn't pong since the previous tick. This is the only thing that
// reliably reaps half-open mobile sockets stranded by background
// suspension without a TCP FIN. See HEARTBEAT_INTERVAL_MS comment.
// Why: the only reliable reaper of half-open mobile sockets stranded by background suspension without a TCP FIN.
private startHeartbeat(): void {
if (this.heartbeatTimer) {
return
@@ -283,9 +219,7 @@ export class WebSocketTransport implements RpcTransport {
let reaped = 0
for (const ws of wss.clients) {
if (!this.wsAlive.has(ws)) {
// Why: terminate() (vs close()) skips the close handshake and
// immediately fires the 'close' event, freeing the slot. close()
// on an already-dead socket can hang for the OS-level TCP timeout.
// Why: terminate() frees the slot immediately; close() on a dead socket can hang for the OS-level TCP timeout.
ws.terminate()
reaped++
continue
@@ -294,12 +228,10 @@ export class WebSocketTransport implements RpcTransport {
try {
ws.ping()
} catch {
// Why: ping() can throw on a socket that's mid-tear-down; the
// close handler will run regardless, so swallow the throw.
// Why: ping() can throw on a mid-teardown socket; the close handler runs regardless, so swallow it.
}
}
// Why: steady reaping or a client count riding the cap are early overload
// signals; surface them without logging on healthy heartbeat ticks.
// Why: steady reaping or riding the cap are early overload signals; stay quiet on healthy ticks.
if (reaped > 0 || wss.clients.size >= MAX_WS_CONNECTIONS) {
console.warn(
`[ws-transport] heartbeat reaped ${reaped}; ${wss.clients.size} tracked sockets`
@@ -327,8 +259,7 @@ export class WebSocketTransport implements RpcTransport {
if (wss) {
for (const client of wss.clients) {
// Why: stop() is a teardown path. A half-open mobile socket may never
// answer a graceful close frame, which keeps httpServer.close pending.
// Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending.
client.terminate()
}
wss.close()
@@ -347,20 +278,14 @@ export class WebSocketTransport implements RpcTransport {
}
}
// Why: WebSocket connections are long-lived (unlike Unix socket which is
// one-per-request). Multiple requests can be multiplexed on the same
// connection via the RPC `id` field. The transport delegates all auth
// and dispatch logic to the message handler set by OrcaRuntimeRpcServer.
// Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler.
private handleConnection(ws: WebSocket): void {
let finalized = false
const onPong = (): void => {
this.wsAlive.add(ws)
}
const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
// Why: any inbound traffic counts as proof of life, not just pongs.
// RN's WebSocket runtime auto-pongs server pings transparently, but
// app-level frames also count toward liveness so an actively-talking
// client doesn't get terminated mid-request.
// Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request.
this.wsAlive.add(ws)
const msg =
typeof data === 'string'
@@ -371,8 +296,7 @@ export class WebSocketTransport implements RpcTransport {
this.messageHandler?.(
msg,
(response) => {
// Why: mobile clients disconnect frequently (backgrounding, network
// switch, phone locked). Guard writes to avoid errors on dead sockets.
// Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket.
if (ws.readyState === ws.OPEN) {
ws.send(response)
}
@@ -381,8 +305,7 @@ export class WebSocketTransport implements RpcTransport {
)
}
const onError = (): void => {
// Why: close is not guaranteed after every ws error path; finalize here
// too so pre-auth E2EE channels and connection ids cannot leak.
// Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak.
finalizeConnection()
ws.close()
}
@@ -405,9 +328,7 @@ export class WebSocketTransport implements RpcTransport {
const preAuthTimer = setTimeout(() => {
if (!this.wsClientIds.has(ws)) {
// Why: a silent client that only auto-pongs can otherwise occupy one
// of the finite mobile WebSocket slots forever without ever starting
// the E2EE handshake.
// Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake.
ws.terminate()
}
}, this.preAuthTimeoutMs)
@@ -416,17 +337,13 @@ export class WebSocketTransport implements RpcTransport {
}
this.preAuthTimers.set(ws, preAuthTimer)
// Why: seed alive=true so the first heartbeat tick after connect doesn't
// treat a fresh socket as dead. Subsequent pongs (or any inbound traffic)
// re-arm it.
// Why: seed alive so the first heartbeat tick doesn't reap a fresh socket before its first pong.
this.wsAlive.add(ws)
ws.on('pong', onPong)
ws.on('message', onMessage)
// Why: mobile clients disconnect when the phone locks, loses wifi, or
// backgrounds the app. The runtime must clean up connection-scoped state
// (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop.
// Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop.
ws.on('close', finalizeConnection)
ws.on('error', onError)
}
+43 -148
View File
@@ -1,9 +1,5 @@
/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */
// Why: this is the single security boundary for the bundled CLI. It owns
// auth-token enforcement, bootstrap-metadata publication, and transport
// orchestration so a running runtime is always discoverable via exactly
// one on-disk file. Method handling lives in `rpc/` and transport specifics
// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`.
// Why: the single security boundary for the bundled CLI — auth-token enforcement, metadata publication, transport orchestration.
import { randomBytes } from 'node:crypto'
import { readdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
@@ -53,15 +49,10 @@ type OrcaRuntimeRpcServerOptions = {
platform?: NodeJS.Platform
enableWebSocket?: boolean
wsPort?: number
// Why: true when the caller set an explicit port (e.g. `orca serve --port`).
// Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind
// order can prefer the pin over a stale STA-1511 fallback (issue #8535).
// Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535).
preferPinnedWsPort?: boolean
webClientRoot?: string
// Why: test-only overrides for the two time-bound constants below.
// Production callers must not pass these — defaults are set by the design
// doc (§3.1) and changing them in production would weaken the admission
// fence or flood the socket with keepalive frames.
// Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1).
keepaliveIntervalMs?: number
longPollCap?: number
}
@@ -88,22 +79,10 @@ export type MobilePairingConnectionContext = Readonly<{
transport: MobileSocketTransportMetadata
}>
// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}`
// frame every 10 s until the handler resolves. Each write resets both the
// server's own socket idle timer (30 s) and — once §3.1 ships on the client —
// the client's idle timer, because any byte counts as socket activity. This
// is the transport-layer fix for feedback #1: long-poll RPCs (i.e.
// orchestration.check --wait) can now run past the 30 s/60 s idle caps
// without either end tearing the socket down. See design doc §3.1.
// Why: keepalive frames count as socket activity, resetting both idle timers so long-polls outlive the 30s/60s idle caps. See §3.1.
const KEEPALIVE_INTERVAL_MS = 10_000
// Why: long-poll slot cap. With keepalives a `check --wait --timeout-ms
// 600000` can hold a connection for up to 10 minutes; unbounded that would
// saturate MAX_RUNTIME_RPC_CONNECTIONS (32) with 32 waiting coordinators
// and lock out normal short RPCs. Capping at half the connection budget
// leaves the other half for short traffic. On overflow the server responds
// immediately with `runtime_busy` (CLI exit 75) — fail fast, not silent
// queuing. See design doc §3.1 + §7 risk #2.
// Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2.
const LONG_POLL_CAP = 16
function resolvePairingEndpoint(rawEndpoint: string, address: string | null | undefined): string {
@@ -148,8 +127,7 @@ function createWebClientUrl(endpoint: string, pairingUrl: string): string {
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'
url.pathname = webClientPathForEndpoint(url.pathname)
url.search = ''
// Why: pairing URLs include full runtime credentials. Keeping them in the
// fragment avoids proxy logs and Referer headers while the web app loads.
// Why: pairing URLs carry full credentials; the fragment keeps them out of proxy logs and Referer headers.
url.hash = `pairing=${encodeURIComponent(pairingUrl)}`
return url.toString()
}
@@ -276,16 +254,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'github.updatePRState',
'github.repoSlug',
'github.workItem',
// Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart
// picker resolve a pasted github.com URL that points at a different repo.
// Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo.
'github.workItemByOwnerRepo',
'github.workItemDetails',
'gitlab.createIssue',
'gitlab.addIssueComment',
'gitlab.addMRComment',
'gitlab.listWorkItems',
// Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact
// issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.)
// Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems).
'gitlab.workItemByPath',
'gitlab.mergeMR',
'gitlab.resolveMRDiscussion',
@@ -416,10 +392,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'worktree.sleep'
])
// Why: a long-poll request is one whose handler blocks waiting for an external
// event. This function is the single place that classifies it — the long-poll
// counter, abort wiring, keepalives, and runtime_busy admission check all
// share this decision. See §3.1.
// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1.
function isLongPollRequest(request: RpcRequest): boolean {
if (request.method === 'terminal.wait') {
return true
@@ -431,10 +404,7 @@ function isLongPollRequest(request: RpcRequest): boolean {
return false
}
// Why: stamp the authenticated connection's scope onto the status.get success
// envelope. status.get has no per-connection context inside the dispatcher, so
// the scope is added here at the transport boundary where the device is known.
// Failures fall back to the untouched reply rather than dropping the response.
// Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary.
function injectDeviceScope(response: string, scope: DeviceScope): string {
try {
const parsed = JSON.parse(response) as RpcResponse
@@ -477,9 +447,7 @@ export class OrcaRuntimeRpcServer {
WebSocket,
{ controllers: Set<AbortController>; abortOnClose: () => void }
>()
// Why: separate from Node's server.maxConnections because we need to count
// only long-running dispatches, not every in-flight short RPC. See §3.1 +
// §7 risk #2.
// Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2.
private activeLongPolls = 0
constructor({
@@ -545,8 +513,7 @@ export class OrcaRuntimeRpcServer {
(current.relayBinding.relayHostId !== binding.relayHostId ||
current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey)
) {
// Why: switching the account/host that owns this local pairing cannot
// strand the old cloud credential family even if that account is offline.
// Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline.
this.queueRelayDeviceRevoke(current.relayBinding)
}
const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
@@ -646,26 +613,20 @@ export class OrcaRuntimeRpcServer {
endpoint: string
deviceId: string
webClientUrl: string | null
/** Mode the returned offer actually encodes 'local-only' when an
* automatic request degraded because Relay could not be attached. */
/** Mode the offer actually encodes — 'local-only' when an automatic request degraded (Relay couldn't attach). */
connectionMode: MobilePairingConnectionMode
}
> {
// Why: the renderer is outside the trust boundary; only the explicit
// local-only value may suppress Relay provisioning.
// Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning.
const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic'
const pending = this.deviceRegistry?.getPendingDevice('mobile')
// Why: an offer's connection policy is part of its credential. Rotating on
// any policy switch here (not in the renderer) means a QR displayed under
// the old policy cannot pair under the new one, and windows reminting after
// a preference sync converge on one token instead of racing rotations.
// Why: connection policy is part of the credential, so rotate on any policy switch — an old-policy QR must not pair under the new one.
const switchingPendingMode =
pending != null &&
this.deviceRegistry?.getMobilePairingConnectionMode(pending.deviceId) !== connectionMode
if (args.rotate || switchingPendingMode) {
if (pending?.relayBinding) {
// Why: the durable cloud revoke is recorded before rotating the local
// token, so a previously displayed relay invite cannot outlive the QR.
// Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR.
this.queueRelayDeviceRevoke(pending.relayBinding)
}
}
@@ -705,8 +666,7 @@ export class OrcaRuntimeRpcServer {
})
}
} catch {
// Why: relay is additive. A transient auth/director/control outage must
// still yield the valid LAN/Tailscale pairing offer.
// Why: relay is additive — a transient outage must still yield the valid LAN/Tailscale pairing offer.
return { ...direct, connectionMode: 'local-only' }
}
}
@@ -771,8 +731,7 @@ export class OrcaRuntimeRpcServer {
abortOnClose: () => this.abortWebSocketDispatches(ws)
}
this.wsDispatchAbortStates.set(ws, state)
// Why: many streaming RPCs can share one WebSocket. A single socket-level
// abort fan-out avoids MaxListenersExceededWarning while preserving cleanup.
// Why: many streaming RPCs share one WebSocket; one socket-level abort fan-out avoids MaxListenersExceededWarning.
ws.on('close', state.abortOnClose)
ws.on('error', state.abortOnClose)
}
@@ -815,12 +774,7 @@ export class OrcaRuntimeRpcServer {
return
}
// Why: processes killed by SIGKILL / OOM-kill / forced-shutdown skip
// stop() and leave behind `o-<pid>-*.sock` files in userData. Sweeping
// dead-pid sockets at startup keeps the directory from accumulating
// orphans over the app's lifetime. Named-pipe transports on Windows do
// not leave filesystem entries in userData, so the sweep is a no-op
// there.
// Why: SIGKILL/OOM skip stop(), orphaning `o-<pid>-*.sock` files; sweep them. Skipped on Windows: named pipes leave no filesystem entries.
if (this.platform !== 'win32') {
sweepOrphanedRuntimeSockets(this.userDataPath, this.pid)
}
@@ -838,15 +792,7 @@ export class OrcaRuntimeRpcServer {
keepaliveIntervalMs: this.keepaliveIntervalMs
})
// Why: Unix socket transport uses the shared runtime auth token. This is
// the existing security model for CLI connections — the token lives in a
// 0o600-permissioned file on disk.
// Why: the `.catch` guarantees `reply()` always fires even if
// `handleMessage` (or `JSON.stringify` on a pathological response) throws.
// Without it, a throw would leave the client waiting for a terminal frame
// that never arrives AND leak the dispatch's AbortController in the
// transport's in-flight set until the 30 s socket idle timer closes the
// connection.
// Why: the `.catch` guarantees reply() always fires so a throw can't strand the client or leak the AbortController.
socketTransport.onMessage((msg, reply, context) => {
void this.handleMessage(msg, context)
.then((response) => {
@@ -854,10 +800,7 @@ export class OrcaRuntimeRpcServer {
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
// Why: best-effort id recovery so the client can correlate the
// error frame to its pending request. A malformed message would
// have been caught by handleMessage and returned an envelope
// instead of throwing, so in practice the id is always present.
// Why: best-effort id recovery so the client can correlate the error frame to its pending request.
let id = 'unknown'
try {
const parsed = JSON.parse(msg) as { id?: unknown }
@@ -876,9 +819,7 @@ export class OrcaRuntimeRpcServer {
const activeTransports: RpcTransport[] = [socketTransport]
const transportsMeta: RuntimeTransportMetadata[] = [transportMeta]
// Why: WebSocket transport is opt-in and starts alongside the Unix socket.
// It uses per-device tokens and E2EE (application-layer encryption via
// tweetnacl) rather than TLS, since React Native can't pin self-signed certs.
// Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs.
if (this.enableWebSocket) {
try {
this.deviceRegistry = new DeviceRegistry(this.userDataPath)
@@ -888,11 +829,7 @@ export class OrcaRuntimeRpcServer {
host: '0.0.0.0',
port: this.wsPort,
staticRoot: this.webClientRoot,
// Why: keep the fallback port stable across restarts so paired
// devices' stored endpoints stay valid (STA-1511) — the transport
// binds a persisted fallback before the preferred port unless the
// caller explicitly pinned a port (serve --port). wsPort 0 means
// the caller wants a random port (E2E) — don't pin it.
// Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E).
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
})
@@ -917,8 +854,7 @@ export class OrcaRuntimeRpcServer {
return
}
this.abortWebSocketDispatches(socket.ws)
// Why: subscriptions and binary streams are socket-scoped, while
// client disconnect state is device-scoped across both transports.
// Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports.
this.runtime.cleanupSubscriptionsForConnection(socket.connectionId)
this.runtime.cancelMobileDictationForConnection(socket.connectionId)
this.binaryStreamHandlers.delete(socket.connectionId)
@@ -940,26 +876,20 @@ export class OrcaRuntimeRpcServer {
endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}`
})
} catch (error) {
// Why: WebSocket transport is supplementary — the runtime must still
// function if it fails to start (e.g., port in use). Log and continue
// with Unix socket only.
// Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only.
console.error('[runtime] Failed to start WebSocket transport:', error)
this.mobileSocketWiring = null
}
}
// Why: publish the transport into in-memory state before writing metadata
// so the bootstrap file always contains the real endpoint/token pair. The
// CLI only discovers the runtime through that file.
// Why: set in-memory transport state before writing metadata so the bootstrap file has the real endpoint/token pair.
this.activeTransports = activeTransports
this.transports = transportsMeta
try {
this.writeMetadata()
} catch (error) {
// Why: a runtime that cannot publish bootstrap metadata is invisible to
// the `orca` CLI. Close all transports immediately instead of leaving
// behind a live but undiscoverable control plane.
// Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable.
this.activeTransports = []
this.transports = []
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
@@ -976,25 +906,15 @@ export class OrcaRuntimeRpcServer {
return
}
await Promise.all(transports.map((t) => t.stop()))
// Why: we intentionally leave the last metadata file behind instead of
// deleting it on shutdown. Shared userData paths can briefly host multiple
// Orca processes during restarts, updates, or development, and stale
// metadata is safer than letting one process erase another live runtime's
// bootstrap file.
// Why: leave the metadata file on shutdown — shared userData may host another live runtime whose bootstrap file we'd erase.
}
// Why: Unix socket messages use one-shot dispatch (single response per
// request) and the shared runtime auth token from the 0o600 metadata file.
// The transport layer owns socket lifecycle, keepalive writes, and the
// per-connection abort signal — this method just parses, auths, and
// dispatches. See design doc §3.1.
// Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1.
private async handleMessage(
rawMessage: string,
context?: RpcMessageContext
): Promise<RpcResponse> {
// Why: empty messages are sent by the Unix socket transport layer when a
// client exceeds the max message size. The transport closes the connection
// after this response.
// Why: the transport sends an empty message when a client exceeds max size, then closes the connection.
if (!rawMessage) {
return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size')
}
@@ -1005,8 +925,7 @@ export class OrcaRuntimeRpcServer {
}
const request = parsed.request
// Why: long-poll admission fence. Short RPCs bypass the counter entirely
// — it only guards handlers that can block for minutes. See §7 risk #2.
// Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2.
const longPoll = isLongPollRequest(request)
if (longPoll && this.activeLongPolls >= this.longPollCap) {
return this.buildError(
@@ -1017,8 +936,7 @@ export class OrcaRuntimeRpcServer {
}
if (longPoll) {
this.activeLongPolls += 1
// Why: arm the keepalive timer only for long-polls. Short RPCs never
// touch it so the `setInterval` is never created. See §3.1.
// Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1.
context?.startKeepalive()
}
@@ -1057,8 +975,7 @@ export class OrcaRuntimeRpcServer {
return { request }
}
// Why: WebSocket messages go through streaming dispatch which can emit
// multiple responses. Auth uses per-device tokens from the device registry.
// Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token.
private async handleWebSocketMessage(
rawMessage: string,
reply: (response: string) => void,
@@ -1093,8 +1010,7 @@ export class OrcaRuntimeRpcServer {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch')))
return
}
// Why: E2EE already authenticated the WebSocket channel. Use that bound
// identity for authorization instead of trusting a repeated request field.
// Why: E2EE already authenticated the channel; authorize by that bound identity, not a repeated request field.
const token = authenticatedDeviceToken ?? requestToken
if (!token) {
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
@@ -1118,8 +1034,7 @@ export class OrcaRuntimeRpcServer {
return
}
// Why: associate the deviceToken with this WebSocket so ws.on('close')
// can notify the runtime which mobile client disconnected.
// Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected.
if (wsTransport && ws) {
wsTransport.setClientId(ws, token)
}
@@ -1143,8 +1058,7 @@ export class OrcaRuntimeRpcServer {
this.activeLongPolls += 1
}
// Why: older/saved WebSocket pairings may not carry scope metadata, so
// stamp the authenticated scope onto the one method that probes the runtime.
// Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get.
const replyForRequest =
request.method === 'status.get'
? (response: string): void => reply(injectDeviceScope(response, device.scope))
@@ -1179,8 +1093,7 @@ export class OrcaRuntimeRpcServer {
await this.dispatcher.dispatchStreaming(request, replyForRequest, {
connectionId,
clientId: token,
// Why: gates the mobile-only payload diet (native-chat char clipping) so
// full-screen web/desktop runtime clients aren't truncated.
// Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated.
clientKind: device.scope,
pairing: pairingContext,
signal: abortRegistration?.signal,
@@ -1212,13 +1125,7 @@ export class OrcaRuntimeRpcServer {
}
}
/**
* Why: the regex MUST stay in lockstep with createRuntimeTransportMetadata()
* below, which emits `o-${pid}-${endpointSuffix}.sock` where endpointSuffix
* is `[A-Za-z0-9_-]{1,4}` (derived from a sanitised runtimeId prefix, or
* `'rt'` as the fallback). The invariant is covered by a unit test so any
* future change to the transport-name shape trips CI.
*/
/** Why: MUST stay in lockstep with createRuntimeTransportMetadata()'s `o-${pid}-${suffix}.sock` shape (unit-test enforced). */
export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/
export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void {
@@ -1226,8 +1133,7 @@ export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number
try {
entries = readdirSync(userDataPath)
} catch {
// Why: first-launch userData may not exist yet; the cold-start path
// below will create it. Nothing to sweep in that case.
// Why: first-launch userData may not exist yet; nothing to sweep.
return
}
for (const entry of entries) {
@@ -1239,28 +1145,19 @@ export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number
if (!Number.isFinite(pid)) {
continue
}
// Why: never touch the current process's socket. start() already
// rmSync's it if it exists, but belt-and-braces — a bug in the own-pid
// path here would rmSync a socket we're about to bind to.
// Why: never delete our own socket — a bug here would rmSync one we're about to bind.
if (pid === ownPid) {
continue
}
try {
// Why: signal 0 is the POSIX liveness probe — it delivers no signal
// but returns success iff the pid resolves AND the caller has
// permission to signal it. ESRCH = no such process; EPERM = pid
// exists but owned by another user, which is extremely unusual on a
// desktop app's userData dir but we conservatively leave those
// sockets alone.
// Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone).
process.kill(pid, 0)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
try {
rmSync(join(userDataPath, entry), { force: true })
} catch {
// Why: best-effort sweep a permission error on unlink is fine
// to ignore; the socket will be cleaned by a later start() or
// by the OS on reboot.
// Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink.
}
}
}
@@ -1277,9 +1174,7 @@ export function createRuntimeTransportMetadata(
if (platform === 'win32') {
return {
kind: 'named-pipe',
// Why: Windows named pipes do not get the same chmod hardening path as
// Unix sockets, so include a per-runtime suffix to avoid exposing a
// stable, guessable control endpoint name across launches.
// Why: named pipes lack the chmod hardening of Unix sockets; a per-runtime suffix avoids a stable, guessable endpoint name.
endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}`
}
}
+20 -75
View File
@@ -61,8 +61,7 @@ export function configureElectronNetworkCompatibility(
if (!shouldDisableHttp2ForElectronNetworking(options)) {
return
}
// Why: Chromium's HTTP/2 switch is process-wide and only works before the
// first session exists, so read the persisted setting during early startup.
// Why: Chromium's HTTP/2 switch is process-wide and only applies before the first session exists, so set it during early startup.
app.commandLine.appendSwitch('disable-http2')
}
@@ -75,11 +74,7 @@ function requestDevParentShutdown(): void {
app.quit()
const forceExitTimer = setTimeout(() => {
// Why: in dev, losing the supervising parent means this Electron process is
// already orphaned from the terminal session. We try app.quit() first so
// normal cleanup still runs, but fall back to app.exit() when macOS quit
// handlers or window-close guards stall and would otherwise leave Orca
// hanging after Ctrl+C ends `pnpm dev`.
// Why: app.quit() may stall on macOS quit handlers or window-close guards, so force-exit after a grace period to avoid a hung dev app.
app.exit(0)
}, DEV_PARENT_SHUTDOWN_GRACE_MS)
@@ -107,9 +102,7 @@ export function installUncaughtPipeErrorGuard(): void {
}
process.off('uncaughtException', onUncaughtException)
// Why: throwing inside an uncaughtException handler makes Node exit with
// status 7, hiding the original fault. Re-throw on the next tick so the
// default fatal-exception path reports the real status and stack.
// Why: throwing inside an uncaughtException handler exits with status 7 and hides the fault; re-throw next tick for the real stack.
setImmediate(() => {
throw error
})
@@ -142,25 +135,14 @@ export function patchPackagedProcessPath(): void {
join(home, 'bin'),
join(home, '.local/bin'),
join(home, '.nix-profile/bin'),
// Why: several agent CLIs ship install scripts that drop binaries into
// tool-specific ~/.<name>/bin directories (opencode's documented fallback,
// Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH
// without shell rc files, so these stay invisible to `which` probes — and
// the Agents settings page reports them as "Not installed" even when the
// user can run them from Terminal. See stablyai/orca#829.
// Why: some agent CLIs install into ~/.<name>/bin; GUI-launched Electron's minimal PATH misses them (stablyai/orca#829).
join(home, '.opencode/bin'),
join(home, '.vite-plus/bin')
)
}
}
// Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm,
// pnpm, yarn, bun) use #!/usr/bin/env node shebangs that need `node` in PATH.
// resolveCodexCommand() can locate the codex binary in these directories, but
// spawning it still fails if node itself isn't in PATH. Adding version manager
// bin paths here fixes all spawn sites (login, rate limits, usage tracking).
// On Windows this also seeds user-local installer dirs, since shell hydration
// is POSIX-only and Start Menu launches can miss user-level PATH updates.
// Why: version-manager CLIs use env-node shebangs, so node must be on PATH or spawns fail (also seeds Windows user-local dirs).
extraPaths.push(...getVersionManagerBinPaths())
const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH'
@@ -179,10 +161,7 @@ export function patchPackagedProcessPath(): void {
export function configureDevUserDataPath(isDev: boolean): void {
const e2eConfig = getMainE2EConfig()
if (e2eConfig.userDataDir) {
// Why: the E2E suite launches a fresh Electron app for each spec. A
// dedicated userData path per launch prevents persisted repos, worktrees,
// and session state from leaking between tests through the shared dev
// profile while still leaving the user's real packaged profile untouched.
// Why: a per-launch userData path stops E2E specs leaking persisted repos/worktrees/session state through the shared dev profile.
app.setPath('userData', e2eConfig.userDataDir)
return
}
@@ -192,34 +171,22 @@ export function configureDevUserDataPath(isDev: boolean): void {
}
const overrideUserDataPath = process.env.ORCA_DEV_USER_DATA_PATH
if (overrideUserDataPath) {
// Why: automated Electron repros need an isolated profile so persisted
// tabs/worktrees from the developer's normal `orca-dev` session do not
// change startup behavior and hide or create window-management bugs.
// Why: automated repros need an isolated profile so the dev's persisted tabs/worktrees don't skew startup and hide window bugs.
app.setPath('userData', overrideUserDataPath)
return
}
// Why: development runs share the same machine as packaged Orca, and both
// publish runtime bootstrap files under userData. Without a dev-only path,
// `pnpm dev` can overwrite the packaged app's runtime pointer and make the
// public `orca` CLI look broken even though the packaged app is still open.
// Why: without a dev-only path, pnpm dev overwrites the packaged app's runtime pointer under userData and breaks the orca CLI.
app.setPath('userData', join(app.getPath('appData'), 'orca-dev'))
}
export function configureOrcaUserDataPathEnv(): void {
// Why: app relaunches can inherit an ORCA_USER_DATA_PATH from an older CLI or
// updater process. Main must canonicalize it before CLI-shared modules build
// runtime-home paths, or migrations can bridge two Orca app-data directories.
// Why: relaunches can inherit a stale ORCA_USER_DATA_PATH; canonicalize before CLI-shared modules build runtime-home paths.
process.env.ORCA_USER_DATA_PATH = app.getPath('userData')
}
export function shouldInstallManagedHooks(isDev: boolean): boolean {
void isDev
// Why: managed hook installation now targets Orca-owned, environment-scoped
// homes for Codex rather than the user's default ~/.codex state, so plain
// dev runs need the install path enabled to keep hook-backed agent statuses
// accurate without an opt-in flag. The remaining agents still rely on the
// shared startup installer loop, so keep the policy uniformly on until
// they are migrated to more granular ownership seams.
// Why: managed hooks now target Orca-owned Codex homes, not ~/.codex, so keep install on for all agents until each gets its own seam.
return true
}
@@ -228,10 +195,7 @@ export function installDevParentDisconnectQuit(isDev: boolean): void {
return
}
// Why: electron-vite dev controls the Electron app over Node IPC so it can
// hot-restart the main process. On macOS, Ctrl+C can stop that parent process
// without terminating the app window, so in dev we quit explicitly when the
// supervising IPC channel disconnects instead of leaving a stray Electron app.
// Why: on macOS Ctrl+C can stop the electron-vite parent without closing the window, so quit when the IPC channel disconnects.
process.once('disconnect', () => {
requestDevParentShutdown()
})
@@ -268,11 +232,7 @@ export function installDevParentWatchdog(isDev: boolean): void {
if (parentPidChanged || parentMissing) {
clearInterval(timer)
// Why: electron-vite's dev runner starts Electron with plain spawn() and
// inherited stdio, not an IPC channel. On macOS that means Ctrl+C can end
// the dev runner while leaving Orca open. Watching the original parent PID
// keeps dev shutdown coupled to the terminal session without affecting the
// packaged app, which is not supervised by electron-vite.
// Why: the dev runner spawns Electron without IPC, so on macOS Ctrl+C leaves Orca open; watch the parent PID to couple shutdown.
requestDevParentShutdown()
}
}, 1000)
@@ -286,9 +246,7 @@ export function installDevParentSignalQuit(isDev: boolean): void {
}
const onSignal = (): void => {
// Why: run-electron-vite-dev forwards terminal shutdown signals to the
// Electron process group; those are dev-supervisor shutdowns too, so the
// detached daemon should not be preserved for warm reattach.
// Why: run-electron-vite-dev forwards terminal shutdown signals here, so don't preserve the detached daemon for warm reattach.
requestDevParentShutdown()
}
@@ -298,19 +256,14 @@ export function installDevParentSignalQuit(isDev: boolean): void {
export function enableMainProcessGpuFeatures(): void {
if (process.platform === 'linux' && getMainE2EConfig().userDataDir) {
// Why: Ubuntu/Xvfb runners can fail Electron startup with
// "GPU process isn't usable" before Playwright sees the first window.
// E2E coverage does not depend on GPU compositing, so keep CI on the
// software path instead of retrying around a crashed app process.
// Why: Ubuntu/Xvfb runners fail Electron startup with "GPU process isn't usable"; E2E needs no GPU, so use the software path.
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-gpu')
return
}
// Why: Blink force-loses the oldest WebGL context past 16 per renderer, and
// each attached terminal pane holds one — a busy worktree (tabs × splits)
// can exceed that, silently downgrading evicted panes to the DOM renderer.
// 128 covers real layouts while keeping a bound so context leaks surface.
// Why: Blink evicts the oldest WebGL context past 16/renderer and each terminal pane holds one, silently downgrading panes to DOM.
// 128 raises the ceiling for real layouts while staying bounded so context leaks still surface.
app.commandLine.appendSwitch('max-active-webgl-contexts', '128')
const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase()
@@ -325,17 +278,13 @@ export function enableMainProcessGpuFeatures(): void {
ozonePlatformHint === 'wayland' ||
ozonePlatform === 'wayland')
if (isLinuxWaylandSession) {
// Why: #5319 reproduces when Wayland loses the eager GPU channel. Keep
// acceleration available, but drop the GPU sandbox and let Chromium open
// the GPU channel lazily on this compositor path.
// Why: #5319 Wayland loses the eager GPU channel; drop the GPU sandbox so Chromium opens it lazily.
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
const existingFeatures = app.commandLine.getSwitchValue('enable-features')
const features = [
// Why: mirror VS Code's conservative Electron GPU-channel startup flags
// instead of opting into Vulkan/SkiaGraphite/unsafe WebGPU globally.
// Terminal acceleration is controlled by xterm WebGL in the renderer.
// Why: mirror VS Code's conservative GPU-channel flags instead of global Vulkan/SkiaGraphite/WebGPU; terminal accel is xterm WebGL.
...(isLinuxWaylandSession ? [] : ['EarlyEstablishGpuChannel', 'EstablishGpuChannelAsync']),
existingFeatures
]
@@ -346,12 +295,8 @@ export function enableMainProcessGpuFeatures(): void {
}
const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features')
// Why: with main-window background throttling on, Chromium's intensive mode
// clamps hidden-page timers to 1/min after 5 minutes, delaying agent-done and
// bell notifications by up to 60s. Keep the normal 1s hidden clamp (rAF and
// rendering still stop) but opt out of the 1/min tier. Callers skip this
// function under GPU fallback (win32-only today); if throttling ever extends
// to Windows, this opt-out must move out of the GPU-gated path.
// Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s.
// This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path.
const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures]
.filter(Boolean)
.join(',')
+17 -66
View File
@@ -1,33 +1,10 @@
// Burst caps for the telemetry transport. Three independent buckets — all
// must be satisfied for an event to transmit or a consent mutation to apply.
//
// (1) Per-event-name token bucket — defends against runaway-`useEffect`
// bugs and repeated error serializations. `agent_error` is rate-limited
// slightly tighter (20/min) than the default (30/min).
//
// (2) Per-session global ceiling (1,000 events) — defends against a
// compromised renderer. A renderer rendering attacker-controllable
// content can invoke `window.api.telemetryTrack` at any rate the
// per-event-name bucket allows; without a global ceiling, 24h of
// abuse at the per-event cap could emit ~650K events and blow the
// PostHog billing cap in one session.
//
// (3) Consent-mutation bucket (≤5 per session, shared across `setOptIn`
// and `acknowledgeBanner`) — a real user flips the Privacy pane
// toggle a handful of times at most; beyond that it is either a bug
// or a compromised renderer.
//
// All buckets reset when `resetBurstCapsForSession()` is called at the start
// of a fresh telemetry session (on `initTelemetry`). Within a session, the
// per-event token bucket refills continuously at its `capacity / 60_000` ms
// rate; the global ceiling and the consent-mutation bucket do not refill
// within a session by design — the whole point of the global ceiling is to
// cap aggregate per-session volume.
//
// Overflow is silent past the first crossing: each bucket logs exactly one
// `console.warn` the first time it rejects an attempt in a given session,
// then drops quietly until the next session reset. Rate-limiting the logs
// themselves is what keeps a pathological caller from DoSing stderr.
// Three independent burst caps; all must pass to transmit an event or apply a consent mutation:
// (1) Per-event token bucket (30/min, agent_error 20/min) — caps runaway useEffects/error spam.
// (2) Per-session ceiling (1,000) — backstops a compromised renderer to protect the PostHog billing cap.
// (3) Consent-mutation bucket (≤5/session, shared setOptIn+acknowledgeBanner) — more is a bug or attack.
// resetBurstCapsForSession() (on initTelemetry) clears all. Per-event refills continuously; the ceiling
// and consent bucket intentionally don't — the point is to cap aggregate per-session volume.
// Overflow logs once per bucket per session then drops silently, so a pathological caller can't DoS stderr.
import { eventSchemas, type EventName } from '../../shared/telemetry-events'
@@ -45,9 +22,7 @@ type TokenBucket = {
warned: boolean
}
// Module-level state. One Store instance / one telemetry session per main
// process; there is no multi-tenant reuse to worry about. `initTelemetry`
// calls `resetBurstCapsForSession()` to start fresh on each session.
// Module-level singleton state one telemetry session per main process, no multi-tenant reuse.
const perEventBuckets = new Map<string, TokenBucket>()
let perSessionCount = 0
let perSessionWarned = false
@@ -66,9 +41,7 @@ function getOrCreateBucket(name: string, now: number): TokenBucket {
perEventBuckets.set(name, bucket)
return bucket
}
// Continuous refill — `capacity` tokens per `WINDOW_MS` ms. Computed
// lazily on each access so we do not need a timer; equivalent to the
// standard token-bucket formula used by most rate limiters.
// Lazy refill on access (avoids a timer): `capacity` tokens per `WINDOW_MS`.
const elapsed = now - bucket.lastRefill
if (elapsed > 0) {
const refill = (elapsed / WINDOW_MS) * bucket.capacity
@@ -79,29 +52,13 @@ function getOrCreateBucket(name: string, now: number): TokenBucket {
}
/**
* Consume one token for the given event name. Returns `true` if the event is
* allowed to proceed, `false` if any of the buckets rejected it.
*
* Ordering rationale: per-event bucket first, per-session ceiling second.
* The per-event bucket is the attention-conserving check (drops runaway
* useEffects early, before counting against the session ceiling); the global
* ceiling is the correctness backstop against a compromised renderer that
* cycles through event names to evade the per-event caps.
* Consume one token for `name`; `true` if allowed, `false` if any bucket rejected it.
* Order matters: the per-event bucket drops runaway useEffects before they count against the
* per-session ceiling, which backstops a renderer cycling names to evade the per-event caps.
*/
export function consumeBurstToken(name: EventName): boolean {
// Reject unknown event names here so renderer-controlled strings cannot
// grow `perEventBuckets` past the fixed `eventSchemas` size. The IPC
// `telemetry:track` handler casts any string to `EventName`, so a
// compromised renderer could otherwise flood unique bogus names and
// unboundedly grow the Map before the validator rejects them. Downstream
// validator still rejects with the proper "unknown event" reason.
//
// Use `Object.hasOwn` rather than `in` — the latter walks the prototype
// chain, so a compromised renderer could pass `'toString'`, `'__proto__'`,
// `'constructor'`, etc. to bypass the guard and seed buckets for every
// `Object.prototype` key. Growth would be bounded (~12 keys) but the whole
// point of this check is to keep the Map size pinned to the compile-time
// `eventSchemas` surface.
// Bound `perEventBuckets`: the IPC handler casts any string to EventName, so reject unknown
// names a compromised renderer could flood. `Object.hasOwn` (not `in`) skips prototype-chain keys.
if (!Object.hasOwn(eventSchemas, name)) {
return false
}
@@ -129,11 +86,8 @@ export function consumeBurstToken(name: EventName): boolean {
}
/**
* Consume one token from the consent-mutation bucket. Returns `true` if the
* caller is allowed to apply a consent mutation, `false` if the per-session
* ceiling has been reached. Renderer-triggered IPC calls are the only
* callers of this bucket main-originated consent mutations bypass IPC and
* are not rate-limited here.
* Consume one consent-mutation token; returns `false` once the per-session ceiling is hit.
* Only renderer IPC calls reach here main-originated mutations bypass IPC and stay uncapped.
*/
export function consumeConsentMutationToken(): boolean {
if (consentMutationCount >= CONSENT_MUTATION_CEILING) {
@@ -149,10 +103,7 @@ export function consumeConsentMutationToken(): boolean {
return true
}
/**
* Reset every bucket. Called at the start of each telemetry session from
* `initTelemetry`. Tests also call it to get a clean slate between cases.
*/
/** Reset every bucket; called on each telemetry session start (`initTelemetry`), and by tests. */
export function resetBurstCapsForSession(): void {
perEventBuckets.clear()
perSessionCount = 0
+46 -200
View File
@@ -1,30 +1,9 @@
// Main-process telemetry transport. One `posthog-node` client per process,
// one source of truth for common props, one `track()` entry that every event
// (main-originated AND IPC-arrived) funnels through. The validator in
// `validator.ts` is the single gate that protects the wire from malformed
// or over-sized payloads; the burst cap in `burst-cap.ts` protects against
// runaway useEffects and a compromised renderer.
//
// Ordering inside `track()` — MUST be preserved:
// 1. shutdown gate — will-quit already set `shuttingDown = true`;
// late IPC arrivals drop, never crash.
// 2. burst cap — O(1). Runs BEFORE consent resolve so an
// opted-out user whose renderer is compromised
// cannot burn handler CPU by forcing a
// settings read + consent evaluation on every
// attempt.
// 3. consent resolve — reads the live settings, never a cached
// boolean. Env-var / CI / opt-out all funnel
// through here.
// 4. validator — schema-level safeParse. Fail-closed.
// 5. posthog.capture — the only place this module calls into the
// vendor SDK.
//
// `$process_person_profile: false` is attached on every capture because
// posthog-node has no init-time equivalent of posthog-js's
// `person_profiles: 'identified_only'` — without the per-capture flag, the
// server SDK would materialize a PostHog person per install_id, which we
// explicitly do not want for anonymous-only events.
// Main-process telemetry transport: one posthog-node client, one `track()` entry that every
// event (main + IPC) funnels through. The ordering inside `track()` — shutdown gate, burst cap,
// consent, validator, capture — MUST be preserved: burst cap runs before consent so a compromised
// opted-out renderer can't force a settings read per event.
// `$process_person_profile: false` is attached per capture because posthog-node has no init-time
// equivalent of posthog-js's `person_profiles: 'identified_only'` (no PostHog person per install_id).
import { randomUUID } from 'node:crypto'
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
@@ -37,31 +16,12 @@ import { getCohortAtEmit } from './cohort-classifier'
import { resolveConsent, type ConsentState } from './consent'
import { commonPropsSchema, validate } from './validator'
// Compile-time feature flag. PR 2 shipped with this `false` so the SDK was
// wired but no event transmitted. PR 3 flips it to `true`. Independent of
// the build-identity gate below: both must be satisfied to transmit, so
// flipping the flag alone still leaves contributor builds silent.
//
// NOTE: config/scripts/verify-telemetry-constants.mjs greps this declaration
// shape (`const TELEMETRY_ENABLED = true|false`) to gate release verification.
// If you refactor this (e.g. let, export, computed-from-env, moved into a
// config object), update the regex in that script too.
// Compile-time feature flag, independent of the build-identity gate — both must be satisfied to transmit.
// NOTE: config/scripts/verify-telemetry-constants.mjs greps `const TELEMETRY_ENABLED = true|false`; keep that shape or update its regex.
const TELEMETRY_ENABLED = true
// Eligible-to-transmit only if the CI release pipeline injected BOTH the
// build-identity constant and a write key. One without the other is treated
// as a pipeline misconfiguration and fails closed. Contributor / `pnpm dev`
// / third-party rebuilds get literal `null` from electron-vite's `define`,
// so `IS_OFFICIAL_BUILD` evaluates `false` at module load. There is no
// runtime env-var fallback.
//
// The `globalThis` dance exists for the vitest harness. `declare const`
// lets TypeScript type-check against the substituted symbols, but vitest
// does not run electron-vite's `define` pass, so the identifiers are
// undefined at test-runtime. Routing the read through `globalThis` gives
// us the compile-time substitution in production and a safe `undefined`
// in tests — both of which resolve to `IS_OFFICIAL_BUILD === false`, which
// is the fail-closed default we want anywhere outside an official CI build.
// Eligible to transmit only if CI injected BOTH build-identity and write key; either alone fails closed, with no runtime env-var override (dev/contributor builds get `null`).
// The `globalThis` reads are for vitest, which skips electron-vite's `define` pass — resolving to `IS_OFFICIAL_BUILD === false` there.
const BUILD_IDENTITY: 'stable' | 'rc' | null =
typeof ORCA_BUILD_IDENTITY !== 'undefined'
? ORCA_BUILD_IDENTITY
@@ -75,9 +35,7 @@ const IS_OFFICIAL_BUILD: boolean =
typeof WRITE_KEY === 'string' &&
WRITE_KEY.length > 0
// Module-level singletons. There is exactly one Store / one main process /
// one telemetry session at a time; threading `store` through every export
// is verbose without buying anything.
// Module-level singletons — one Store / process / telemetry session; threading `store` everywhere buys nothing.
let posthog: PostHog | null = null
let sessionId: string | null = null
let commonProps: CommonProps | null = null
@@ -86,24 +44,14 @@ let storeRef: Store | null = null
const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000
// Test-only override for the transport gate. Set by `_enableTransportForTests`
// so the client.test.ts suite can exercise the full pipeline (burst cap,
// consent, validator, capture) without waiting on a real CI build. Left
// `false` in production; an accidental call from non-test code would still
// be bounded by `resolveConsent` + the validator.
// Test-only transport-gate override (`_enableTransportForTests`) so tests exercise the full pipeline without a real CI build.
let testTransportEnabled = false
// First-launch `app_opened` session gate. The existing-user banner contract is:
// no events transmit until the notice resolves. Keep "mark" and "emit"
// atomic so no path can accidentally suppress the event without firing it.
// First-launch `app_opened` gate: no events transmit until the banner resolves; keep mark+emit atomic.
let appOpenedTrackedThisSession = false
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
// `.max(64)` on every free-form string field in `commonPropsSchema` is the
// upper bound; node's platform / arch / release strings are always well
// under that in practice. We do not truncate here because the validator's
// schema cap is the authoritative check — truncating pre-validator would
// silently mask an unexpected-long-string case we want to see as a drop.
// Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked.
return {
app_version: app.getVersion(),
platform: osPlatform(),
@@ -116,14 +64,11 @@ function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'r
}
export function initTelemetry(store: Store): void {
// Set `storeRef` unconditionally so `setOptIn` can persist consent
// changes even in console-mirror builds — opt-out must still write to
// disk on a contributor laptop, not just on official builds.
// Set unconditionally so `setOptIn` can persist opt-out to disk even on contributor / non-official builds.
storeRef = store
resetBurstCapsForSession()
shuttingDown = false
// Gate reset per session: the "no app_opened until banner resolution"
// invariant is per-launch, not across the lifetime of the install.
// Reset per session: the "no app_opened until banner resolution" invariant is per-launch, not per-install.
appOpenedTrackedThisSession = false
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) {
@@ -133,8 +78,7 @@ export function initTelemetry(store: Store): void {
const settings = store.getSettings()
const installId = settings.telemetry?.installId
if (!installId) {
// Migration guarantees this is set; if it isn't, we're in an invariant-
// violation state and must not transmit with a missing distinct_id.
// Migration guarantees installId; if missing, don't transmit with an absent distinct_id.
console.warn('[telemetry] installId missing after migration; skipping transport init')
return
}
@@ -143,23 +87,12 @@ export function initTelemetry(store: Store): void {
commonProps = buildCommonProps(
installId,
sessionId,
// Non-null at this point: `IS_OFFICIAL_BUILD` gated this branch and
// narrows the identity constant to the `'stable' | 'rc'` arm.
// Non-null here: `IS_OFFICIAL_BUILD` gated this branch to the `'stable' | 'rc'` arm.
BUILD_IDENTITY as 'stable' | 'rc'
)
// Fail-closed on bad common props — the validator is the single enforcement
// point for wire shape, including common props. A bad `install_id` (e.g.
// empty string from a migration bug) would collapse all events into one
// distinct_id, so we must refuse to initialize transport rather than ship
// malformed identity on every capture.
//
// Validated once here at init — NOT on every `track()` call — because
// `commonProps` is a module-level singleton built exactly once from inputs
// that do not change across the session (app version, OS, install_id,
// session_id, channel). Re-validating per event would be wasted work on
// a value that cannot drift. If a future refactor makes `commonProps`
// mutable mid-session, move this check accordingly.
// Fail-closed: a bad `install_id` (e.g. empty from a migration bug) would collapse all events into one distinct_id.
// Validated once here (not per `track()`): `commonProps` is a session-lifetime singleton that can't drift.
const parsedCommon = commonPropsSchema.safeParse(commonProps)
if (!parsedCommon.success) {
console.warn('[telemetry] common props failed schema validation; skipping transport init')
@@ -171,15 +104,9 @@ export function initTelemetry(store: Store): void {
host: 'https://us.i.posthog.com',
flushAt: 20,
flushInterval: 10_000,
// Strip every auto-attached property we do not want on our wire: no
// GeoIP, no client IP enrichment. Our wire is exactly
// `CommonProps EventProps a small allow-list of SDK auto-props`.
// Strip SDK-auto GeoIP / client-IP enrichment; our wire is exactly CommonProps EventProps a small allow-list.
disableGeoip: true,
// Default is 1000; past that, the SDK drops oldest-first. Bumped to
// 5000 to tolerate long-offline sessions (flights, VPN-down, tunnels).
// The per-session 1,000-event ceiling in `track()` caps normal
// operation well below this; the 5000 slots are the absolute ceiling
// across any conceivable offline duration.
// Bumped from the default 1000 (drops oldest-first past cap) to 5000 to tolerate long-offline sessions.
maxQueueSize: 5000
})
@@ -189,27 +116,12 @@ export function initTelemetry(store: Store): void {
}
/**
* Decide whether to flip the PostHog SDK's in-memory `optedOut` flag at boot.
* Whether to flip the PostHog SDK's in-memory `optedOut` flag at boot.
*
* Applied to DISABLED cohorts only (`user_opt_out` / CI / DO_NOT_TRACK /
* ORCA_TELEMETRY_DISABLED). The SDK flag does not persist across process
* restarts, so we re-apply on every boot as defense-in-depth: any direct
* `posthog.capture()` that bypasses `track()` (and therefore bypasses the
* consent gate in this module) must still drop at the SDK boundary for a
* user who has opted out.
*
* Intentionally NOT applied to `pending_banner`: the existing-user Turn-off
* path in `setOptIn(_, false)` does a direct `posthog.capture()` for the
* `telemetry_opted_out { via: 'first_launch_banner' }` signal, bypassing
* `track()` (see the long comment in the opt-out branch below explaining
* why). If the SDK were already opted-out at that point, the capture would
* silently drop inside posthog-core's `enqueue()` losing the one signal
* that tells us the opt-out flow works. `track()`'s own consent gate
* (`resolveConsent() !== 'enabled'`) still drops every other event while
* the cohort is `pending_banner`, so there is no risk of stray transmission
* during the pre-banner window.
*
* Exported for tests; production has exactly one call site above.
* True for DISABLED cohorts only, re-applied every boot (the flag doesn't persist) so any direct
* `posthog.capture()` bypassing `track()` still drops for an opted-out user. Deliberately excludes
* `pending_banner`: the direct `telemetry_opted_out` capture in `setOptIn(_, false)` must not drop,
* or we'd lose the one signal that the opt-out flow works.
*/
export function shouldOptOutSdkAtInit(consent: ConsentState): boolean {
return consent.effective === 'disabled'
@@ -233,8 +145,7 @@ function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string):
resolve(enqueued)
}
// Why: posthog-node's capture() prepares/enqueues asynchronously; this
// public SDK event is the durable boundary we need before calling optOut().
// Why: posthog-node capture() enqueues async; this SDK event is the durable boundary before optOut().
stopListening = client.on('capture', (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
return
@@ -249,18 +160,13 @@ function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string):
})
}
// In `pnpm dev` and any contributor / non-official build, `track()` is a
// no-op: it returns immediately without transmitting, logging, or running
// the burst-cap / consent / validator pipeline. Telemetry only flows in
// official stable/rc builds where CI injects `ORCA_BUILD_IDENTITY` and
// `ORCA_POSTHOG_WRITE_KEY`.
// No-op in contributor / non-official builds; only official stable/rc builds (CI-injected `ORCA_BUILD_IDENTITY` + `ORCA_POSTHOG_WRITE_KEY`) transmit.
export function track<N extends EventName>(name: N, props: EventProps<N>): void {
if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) {
return
}
// (1) Shutdown gate. Late IPC arrivals should not attempt to enqueue
// against a client that is actively flushing.
// (1) Shutdown gate: late IPC arrivals must not enqueue against a flushing client.
if (shuttingDown) {
return
}
@@ -268,37 +174,24 @@ export function track<N extends EventName>(name: N, props: EventProps<N>): void
return
}
// (2) Burst cap BEFORE consent. A compromised renderer of an opted-out
// user should not be able to burn CPU by forcing a settings read and a
// `resolveConsent` evaluation on every attempt — the cap is O(1), the
// consent resolve reads the live settings object. This ordering is the
// difference between "opt-out is a free drop" and "opt-out is a cheap
// drop at the cost of a settings read per event."
// (2) Burst cap before consent: the O(1) cap drops floods before the costly settings read, so a compromised opted-out renderer can't burn CPU.
if (!consumeBurstToken(name)) {
return
}
// (3) Consent resolve — reads live settings every call; never a cached
// module-level boolean that could drift from the persisted state or the
// env-var precedence.
// (3) Consent resolve — reads live settings every call so it can't drift from persisted state / env-var precedence.
const consent = resolveConsent(storeRef.getSettings())
if (consent.effective !== 'enabled') {
return
}
// (4) Validator — single enforcement point for schema, enum, strict key
// set, and per-string length caps.
// (4) Validator — single enforcement point for schema, enum, key set, and length caps.
const result = validate(name, props)
if (!result.ok) {
return
}
// (5) Capture. `$process_person_profile: false` is the server-SDK
// equivalent of posthog-js's `person_profiles: 'identified_only'` —
// attached per-event because posthog-node has no init-time option.
// Without this, posthog-node materializes a PostHog person per
// `install_id`, which we explicitly do not want for anonymous-only
// events.
// (5) Capture. `$process_person_profile: false` stops posthog-node creating a person per install_id (no init-time equivalent).
posthog.capture({
distinctId: commonProps.install_id,
event: name,
@@ -319,10 +212,7 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
const wasPendingBanner =
telemetryBeforeUpdate?.existedBeforeTelemetryRelease === true &&
telemetryBeforeUpdate.optedIn === null
// `updateSettings` is a partial-merge (see persistence.ts:552). The Store's
// `telemetry` field is deep-merged there specifically so an `optedIn` flip
// from the Privacy pane / consent flow does not clobber `installId` or
// `existedBeforeTelemetryRelease`.
// Deep-merge (persistence.ts:552) so flipping `optedIn` won't clobber `installId` / `existedBeforeTelemetryRelease`.
storeRef.updateSettings({
telemetry: {
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
@@ -343,23 +233,8 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
if (!client) {
return
}
// Fire opt-out event BEFORE disabling the SDK. This is the one event
// that transmits against the user's new preference — the user chose to
// tell us they are opting out, and that single signal is what tells us
// the opt-out flow is working.
//
// Capture directly (not via `track()`) because `updateSettings` above
// just flipped `optedIn` to `false`; `track()` would re-read settings,
// call `resolveConsent`, and drop on `user_opt_out` — at which point the
// one signal that tells us the opt-out flow works would be silent.
// Burst cap + validator still run; consent is the only gate bypassed,
// and it is bypassed exactly once per user per session at most (IPC
// consent-mutation cap is 5/session).
//
// posthog-node prepares capture() asynchronously, so "call capture
// before optOut" is not enough; wait until the SDK confirms enqueue.
// We do not wait for network flush here — the SDK queue and shutdown
// flush own delivery, while the enqueue boundary owns the optOut race.
// Fire before disabling the SDK — the one event that must transmit against the new preference. Capture directly (not
// `track()`, which would drop it on `user_opt_out`); await enqueue since posthog-node captures async and must confirm before optOut().
try {
if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) {
const validated = validate('telemetry_opted_out', { via })
@@ -389,35 +264,15 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
}
}
// Banner ✕ path. Writes `optedIn = true` permanently without emitting a
// telemetry opt-in event. `app_opened` still fires because resolving the
// banner is the first point where this session is eligible to transmit.
// That outcome cannot route through `setOptIn()` — `setOptIn()` always
// fires a `telemetry_opted_in/out` event and the IPC handler always
// derives a non-`null` `via` value, which would tag a ✕ click as
// `first_launch_banner` + `telemetry_opted_in`. The ✕-as-silent-
// acknowledge contract is explicit: the user did not explicitly opt in,
// they declined to intervene, so no opt-in event transmits.
//
// So this primitive exists as a named, non-overloaded code path: persist
// the opt-in, unlock the SDK, and fire the once-per-session app-opened event.
// The corresponding `telemetry:acknowledgeBanner` IPC channel
// routes renderer ✕ clicks here instead of through `telemetry:setOptIn`.
//
// Do NOT extend this with a `via` parameter or emission flag. If a future
// surface also needs a silent persisted opt-in, give it its own named
// function rather than overloading this one — the grep'ability of
// `persistBannerAcknowledgeWithoutEmitting` is the whole point.
// Banner ✕: silent persisted opt-in. Separate from `setOptIn` because that always emits a
// `telemetry_opted_in/out` event; here `app_opened` fires but no opt-in event does. Don't add a
// `via`/emit param — give a new silent-opt-in surface its own named function.
export async function persistBannerAcknowledgeWithoutEmitting(): Promise<void> {
if (!storeRef) {
return
}
const settings = storeRef.getSettings()
// Defensive merge mirrors `setOptIn`: updateSettings deep-merges the
// telemetry block (persistence.ts:560), so the fallback object here only
// matters if the migration invariant has been violated and `telemetry`
// is somehow absent — in which case we still want to persist an opt-in
// rather than no-op.
// Fallback only used if the `telemetry` block is absent (migration invariant broken); updateSettings deep-merges it (persistence.ts:560).
storeRef.updateSettings({
telemetry: {
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
@@ -427,8 +282,7 @@ export async function persistBannerAcknowledgeWithoutEmitting(): Promise<void> {
if (posthog) {
await posthog.optIn()
}
// Why: resolving the banner is the first eligible moment for app_opened.
// Re-enable the SDK first so capture sees the new consent state.
// Why: banner resolution is the first eligible moment for app_opened; SDK re-enabled above so capture sees the new consent.
trackAppOpenedOnce()
}
@@ -437,23 +291,19 @@ export function trackAppOpenedOnce(): void {
return
}
appOpenedTrackedThisSession = true
// Why: `nth_repo_added: 0` on `app_opened` is the canonical session-zero
// / pre-repo cohort signal — a user who has launched but never added a
// repo. See docs/onboarding-funnel-cohort-addendum.md.
// Why: `nth_repo_added: 0` marks the session-zero / pre-repo cohort. See docs/onboarding-funnel-cohort-addendum.md.
track('app_opened', { ...getCohortAtEmit() })
}
export async function shutdownTelemetry(): Promise<void> {
// Setting the shutdown gate is synchronous and cheap — it matters that
// late IPC-arrived tracks hit it before the bounded flush starts.
// Set the gate before flush so late IPC-arrived tracks drop instead of enqueuing mid-flush.
shuttingDown = true
const instance = posthog
if (!instance) {
return
}
try {
// PostHog's bounded flush caps at 2s. Observed quit delay goes up by at
// most that on top of the current daemon-teardown budget.
// Bounded flush caps at 2s, so quit delay rises by at most that.
await instance.shutdown(2_000)
} catch (err) {
// Telemetry must never crash the app on quit. Swallow.
@@ -461,11 +311,7 @@ export async function shutdownTelemetry(): Promise<void> {
}
}
// ── Test-only introspection ─────────────────────────────────────────────
//
// The test suite needs to inject a fake PostHog and observe capture calls
// without touching the network. Kept under a `_`-prefixed name so it is
// obvious in code review that this is not a runtime API.
// Test-only introspection: `_`-prefixed helpers inject a fake PostHog and observe captures; not a runtime API.
export function _setPostHogClientForTests(client: PostHog | null): void {
posthog = client
@@ -1,36 +1,6 @@
// Cohort discriminator for onboarding-wizard telemetry events. See
// docs/onboarding-telemetry-extensions.md §2.
//
// `'fresh_install'` ⇔ `existedBeforeTelemetryRelease === false`. New users
// land on the wizard on first launch; the cohort never moves.
//
// `'upgrade_backfill'` ⇔ `existedBeforeTelemetryRelease === true` AND the
// onboarding state was backfilled at load time as a completed wizard run
// (the persistence migration at `src/main/persistence.ts:362-369` writes
// `outcome: 'completed'` and `lastCompletedStep: ONBOARDING_FINAL_STEP` for
// existing users that lack an onboarding block). A pre-existing user who is
// dropped into the wizard via the upgrade-backfill surface emits this
// cohort on every wizard event.
//
// Known limitation: the discriminator infers the migration-backfilled state
// from its canonical shape (`outcome === 'completed'` AND
// `lastCompletedStep === ONBOARDING_FINAL_STEP`). That shape is *also* what
// a live wizard completion writes via `closeWith('completed', ...)`. As a
// result, a real existing-user (`existedBeforeTelemetryRelease === true`)
// who goes through the wizard live will be classified as `fresh_install`
// during the wizard, then *flip* to `upgrade_backfill` on the very next
// event after `closeWith` persists the completion. Dashboard-side
// workaround: filter `cohort` on the `_started` event and forward-fill
// across the session, rather than re-reading the cohort on terminal
// events. Structural follow-up (out of scope here): add a sentinel
// `wasBackfilledByMigration: true` field at migration time so the
// classifier can disambiguate without dashboard-side gymnastics.
//
// Failure mode: this module never throws. On any read error or
// store-not-yet-initialized condition, `getOnboardingCohortAtEmit` returns
// `{ cohort: undefined }`. The schemas declare the field `.optional()`, so
// an event with an undefined cohort still validates and emits — it just
// lands without the cohort property. Mirrors `getCohortAtEmit`.
// Cohort discriminator for onboarding-wizard telemetry. See docs/onboarding-telemetry-extensions.md §2.
// Known limitation: upgrade_backfill's "completed" shape (persistence.ts:362-369) is also written by live completion, so an existing live-completer flips fresh_install→upgrade_backfill; dashboards forward-fill cohort from _started. TODO: a wasBackfilledByMigration sentinel would disambiguate.
// Never throws: returns { cohort: undefined } on any read/uninit error, which the schema's .optional() cohort still validates. Mirrors sibling getCohortAtEmit's never-crash contract.
import { ONBOARDING_FINAL_STEP } from '../../shared/constants'
import type { OnboardingCohort } from '../../shared/telemetry-events'
@@ -51,29 +21,14 @@ export function getOnboardingCohortAtEmit(): { cohort: OnboardingCohort | undefi
return { cohort: undefined }
}
try {
// Why: fresh_install classification depends only on the settings flag,
// so we read settings first and skip getOnboarding() entirely on that
// branch — a failing onboarding read must not demote a fresh-install
// user to `{ cohort: undefined }`.
// Why: read settings first so a failing getOnboarding() can't demote a fresh_install user to undefined.
const settings = storeRef.getSettings()
const existedBefore = settings.telemetry?.existedBeforeTelemetryRelease
if (existedBefore === false) {
return { cohort: 'fresh_install' }
}
if (existedBefore === true) {
// Why: an existing-user cohort marker can coexist with a fresh
// wizard run (the migration only backfills when there's no
// onboarding block on disk). The `upgrade_backfill` cohort is
// specifically the user who was force-completed by the migration —
// detected by the canonical `outcome === 'completed'` AND
// `lastCompletedStep === ONBOARDING_FINAL_STEP` shape that
// persistence.ts:362-369 writes. Caveat: the same canonical shape is
// produced by `closeWith('completed', ...)` after a live wizard run,
// so an existing user who completes the wizard live will be
// classified as `fresh_install` during the wizard and then flip to
// `upgrade_backfill` on the next event after completion is
// persisted. See the top-of-file "Known limitation" block for the
// dashboard-side workaround and the proposed sentinel-field fix.
// Why: this canonical completed shape is written by both migration backfill and live completion, so it's ambiguous (see top-of-file "Known limitation").
const onboarding = storeRef.getOnboarding()
if (
onboarding.outcome === 'completed' &&
+15 -48
View File
@@ -88,16 +88,12 @@ export function registerAutoUpdaterHandlers({
setAvailableVersion,
setUserInitiatedCheck
}: UpdaterHandlerContext): void {
// On macOS, electron-updater's MacUpdater downloads the ZIP from GitHub,
// then serves it to Squirrel.Mac via a localhost proxy. The electron-updater
// 'update-downloaded' event fires BEFORE Squirrel finishes its download.
// Track Squirrel readiness so we don't show "ready to install" prematurely.
// Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready".
if (process.platform === 'darwin') {
nativeUpdater.on('update-downloaded', () => {
const hasNewerVersion = hasNewerDownloadedVersion()
handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => {
// If we were holding the 'downloaded' status, send it now — but only
// when the staged version is actually newer than what's running.
// Send the held 'downloaded' status now, only if the staged version is newer.
sendStatus({
state: 'downloaded',
version: getPendingInstallVersion(),
@@ -116,11 +112,7 @@ export function registerAutoUpdaterHandlers({
return
}
// On macOS the user can quit while Squirrel.Mac is still pulling the ZIP
// from electron-updater's localhost proxy. If we let that quit finish,
// autoInstallOnAppQuit has nothing staged to apply and the next launch
// comes back on the old version. Hold the quit, then resume install when
// nativeUpdater confirms ShipIt is actually ready.
// Why: quitting before Squirrel.Mac finishes staging leaves nothing to install; hold the quit until it's ready.
if (
deferMacQuitUntilInstallerReady(
getCurrentStatus(),
@@ -165,8 +157,7 @@ export function registerAutoUpdaterHandlers({
if (compareVersions(info.version, app.getVersion()) <= 0) {
clearAvailableUpdateContext()
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: a fallback manifest at the current version is still the result of
// a transient missing primary manifest, so keep the short retry cadence.
// Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
@@ -178,18 +169,13 @@ export function registerAutoUpdaterHandlers({
return
}
// Why: fetching changelog in the main process avoids CORS issues that
// would block a renderer-side fetch to onorca.dev, and ensures the
// card can render immediately without an async loading gap.
// Why: fetch the changelog in main to avoid renderer-side CORS on onorca.dev.
markUpdateAvailableEventPending(attemptId)
void (async () => {
try {
const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null)
// Why: the handler is now async, so up to 5 seconds may pass during the
// fetch. If another autoUpdater event (e.g., 'error') fired and updated
// the attempt during that window, broadcasting 'available' here would
// overwrite a more recent check. Guard on the attempt before state.
// Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast.
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
@@ -197,18 +183,11 @@ export function registerAutoUpdaterHandlers({
return
}
// --- post-await side effects (only run if the guard passed) ---
// Why: these must live AFTER the guard, not before the await. If the
// fetch times out and a concurrent 'error' event advanced the status,
// bailing out above avoids orphaned side effects — e.g., availableVersion
// set without a matching 'available' broadcast, or a completed-check
// timestamp persisted for a check that never showed a result.
// Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state.
setAvailableVersion(info.version)
setAvailableReleaseUrl(null)
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: offering a previous/last-good release is only a temporary
// fallback; keep probing soon so users can move to the newest tag once
// its platform manifest finishes publishing.
// Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
@@ -236,9 +215,7 @@ export function registerAutoUpdaterHandlers({
setUserInitiatedCheck(false)
clearAvailableUpdateContext()
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: the primary/newest release manifest/assets were missing, so a
// last-good not-available result is still a transient release-transition
// outcome and must not suppress the next retry for 24 hours.
// Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
@@ -260,9 +237,7 @@ export function registerAutoUpdaterHandlers({
autoUpdater.on('update-downloaded', (info) => {
clearBackgroundCheckLaunchPending()
// Don't show the banner if the downloaded version isn't actually newer
// than what's running. This catches the exact-same-version case as well
// as stale cached updates from an older release.
// Skip the banner for non-newer versions (same-version or stale cached updates).
if (compareVersions(info.version, app.getVersion()) <= 0) {
clearAvailableUpdateContext()
sendStatus({ state: 'not-available' })
@@ -270,12 +245,9 @@ export function registerAutoUpdaterHandlers({
}
const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true
recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady })
// On macOS, defer the 'downloaded' status until Squirrel.Mac has finished
// processing the update via the localhost proxy. On other platforms,
// the update is ready immediately after electron-updater downloads it.
// On macOS, defer 'downloaded' until Squirrel.Mac finishes processing; other platforms are ready immediately.
if (process.platform === 'darwin' && !macInstallerReady) {
// Squirrel is still processing. Keep the UI at 100% downloaded so the
// user sees the handoff instead of a misleading "ready to install".
// Keep the UI at 100% downloaded while Squirrel processes, to avoid a premature "ready to install".
recordUpdaterLifecycle('macos_waiting_for_squirrel', { version: info.version })
sendStatus({ state: 'downloading', percent: 100, version: info.version })
return
@@ -285,20 +257,15 @@ export function registerAutoUpdaterHandlers({
autoUpdater.on('error', (err) => {
const message = err?.message ?? 'Unknown error'
// Why: quitAndInstall reports the common "no staged update" failure through
// this event (often sync on Win/Linux, async on macOS/spawn). Recover
// quit-for-update flags before any suppression guard can early-return, but
// only after native invoke and only when install is not yet committed.
// Why: quitAndInstall reports "no staged update" via this error event (async on macOS); recover quit flags before suppression guards run.
if (handleQuitAndInstallFailure()) {
return
}
// Why: handoff still owns the process (cleanup, native in-flight, or
// post-commit). Do not treat as check/download error or reset mac install.
// Why: handoff still owns the process; don't treat as a check/download error.
if (isQuitAndInstallHandoffActive()) {
return
}
// Why: primary/fallback promise handlers may already own this failure; do
// not let their delayed paired error event consume fallback context.
// Why: fallback promise handlers may already own this failure; don't consume fallback context here.
if (shouldSuppressMissingManifestPrereleaseFallbackEvent(message, err)) {
return
}
+22 -61
View File
@@ -187,8 +187,7 @@ describe('updater', () => {
setupAutoUpdater(mainWindow as never)
// Why: E2E launches use dev mode and Electron's direct script runner, whose
// default app version makes electron-updater throw during module load.
// Why: E2E dev-mode launches use a default app version that makes electron-updater throw during module load.
expect(autoUpdaterMock.updateConfigPath).toBeUndefined()
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
@@ -260,8 +259,7 @@ describe('updater', () => {
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
// Why: the raw electron-updater message is replaced with a user-friendly
// one so we never surface "net::ERR_FAILED" directly to the UI.
// Why: the raw electron-updater message is replaced so we never surface "net::ERR_FAILED" to the UI.
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'error', message: 'net::ERR_FAILED' })
)
@@ -1001,9 +999,7 @@ describe('updater', () => {
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: pass a recent timestamp so the startup background check is
// deferred. We want to observe the state of the updater *before* any
// RC-mode call, not race with the startup check.
// Why: recent timestamp defers the startup check so we observe updater state before any RC-mode call, without racing.
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
@@ -1304,8 +1300,7 @@ describe('updater', () => {
vi.useFakeTimers()
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
// Why: BaseUpdater dispatches 'error' synchronously inside install() for
// the common "no staged update filepath" path.
// Why: BaseUpdater dispatches 'error' synchronously inside install() for the common "no staged update filepath" path.
autoUpdaterMock.emit(
'error',
new Error("No update filepath provided, can't quit and install")
@@ -1323,8 +1318,7 @@ describe('updater', () => {
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(isQuittingForUpdate()).toBe(false)
// Why: destructive prep runs only after quitAndInstall returns still in
// progress; sync recovery clears flags first so PTYs stay alive.
// Why: destructive prep runs only after quitAndInstall returns still in progress; sync recovery clears flags first so PTYs stay alive.
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
@@ -1353,8 +1347,7 @@ describe('updater', () => {
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
// Why: put status in downloaded so a naive error handler would otherwise
// treat a late post-commit error as a download/install UI failure.
// Why: reach the downloaded state so a late post-commit error isn't mistaken for a download/install UI failure.
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
@@ -1365,9 +1358,7 @@ describe('updater', () => {
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
// Why: on macOS install is only "committed" once Squirrel is ready; mark
// it ready so this test covers the post-commit path on all platforms and
// the UI can leave the "waiting for Squirrel" downloading state.
// Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the post-commit path on all platforms.
if (process.platform === 'darwin') {
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
@@ -1394,8 +1385,7 @@ describe('updater', () => {
autoUpdaterMock.emit('error', new Error('late post-commit install error'))
expect(isQuittingForUpdate()).toBe(true)
// Why: handoff still owns the process after commit — no recovery message
// and no general check/download error status either.
// Why: handoff still owns the process after commit — no recovery message or check/download error status.
expect(sendMock).not.toHaveBeenCalled()
})
@@ -1425,8 +1415,7 @@ describe('updater', () => {
})
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
// Why: on macOS install only commits once Squirrel is ready; mark it ready
// so this test covers the committed path on all platforms.
// Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the committed path on all platforms.
if (process.platform === 'darwin') {
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
@@ -1442,8 +1431,7 @@ describe('updater', () => {
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
// Why: the installer (ShipIt/NSIS) waits for this process to exit; the
// watchdog guarantees a wedged async shutdown cannot strand the update.
// Why: the installer (ShipIt/NSIS) waits for this process to exit; the watchdog prevents a wedged async shutdown from stranding the update.
expect(armExitWatchdogMock).toHaveBeenCalledTimes(1)
})
@@ -1466,8 +1454,7 @@ describe('updater', () => {
await vi.advanceTimersByTimeAsync(100)
expect(isQuittingForUpdate()).toBe(false)
// Why: recovery leaves the app running a live watchdog would force-exit
// a healthy session 20s later.
// Why: recovery leaves the app running; a live watchdog would force-exit a healthy session 20s later.
expect(armExitWatchdogMock).not.toHaveBeenCalled()
expect(disarmExitWatchdogMock).toHaveBeenCalled()
})
@@ -1498,8 +1485,7 @@ describe('updater', () => {
expect(isQuittingForUpdate()).toBe(true)
sendMock.mockClear()
// Why: an unrelated error during pre-quit cleanup must not clear
// quittingForUpdate or emit the install-recovery status (native not invoked).
// Why: unrelated error during pre-quit cleanup must not clear quittingForUpdate or emit install-recovery status (native not invoked).
autoUpdaterMock.emit('error', new Error('pre-native concurrent error'))
expect(isQuittingForUpdate()).toBe(true)
@@ -1722,8 +1708,7 @@ describe('updater', () => {
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: pass a recent timestamp so the normal startup check is deferred,
// letting the nudge check run without hitting the 'checking' guard.
// Why: recent timestamp defers the startup check so the nudge check runs without hitting the 'checking' guard.
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now()
})
@@ -1818,10 +1803,7 @@ describe('updater', () => {
// Wait for the startup nudge check to run
await new Promise((resolve) => setTimeout(resolve, 0))
// The normal startup check is in progress (status is 'checking').
// The nudge fetch completed but the guard should have prevented
// calling runBackgroundUpdateCheck because currentStatus is 'checking'.
// Only the startup check should have called checkForUpdates.
// The 'checking' guard should block runBackgroundUpdateCheck while the startup check is in progress.
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
@@ -1843,8 +1825,7 @@ describe('updater', () => {
// Wait for the startup nudge check to complete
await vi.advanceTimersByTimeAsync(0)
// The startup check already set lastNudgeCheckAt. Triggering
// browser-window-focus should be blocked by the 5-minute cooldown.
// Startup check set lastNudgeCheckAt, so browser-window-focus is blocked by the 5-minute cooldown.
fetchNudgeMock.mockClear()
appMock.emit('browser-window-focus')
await vi.advanceTimersByTimeAsync(0)
@@ -1891,8 +1872,7 @@ describe('updater', () => {
// Now simulate the updater finding no update
autoUpdaterMock.emit('update-not-available')
// Pending should be cleared and campaign should be auto-dismissed
// so it doesn't re-fire on the next poll cycle
// Pending cleared and campaign auto-dismissed so it doesn't re-fire next poll cycle.
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
})
@@ -2038,13 +2018,7 @@ describe('updater', () => {
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
// Why: the Windows auto-updater must keep electron-updater's built-in
// Authenticode verification, which checks the downloaded installer against
// the SignPath Foundation publisherName that electron-builder embeds in
// app-update.yml. A no-op verifyUpdateCodeSignature override would silently
// accept every installer, so setup must NOT install one. (The issue #631
// stale-publisherName problem that once justified an override is resolved now
// that SignPath builds embed the correct publisherName.)
// Why: a no-op verifyUpdateCodeSignature override would silently accept every installer; keep electron-updater's Authenticode check (issue #631 resolved).
it('does not disable Windows Authenticode verification on win32', async () => {
vi.stubGlobal('process', { ...process, platform: 'win32' })
@@ -2071,12 +2045,7 @@ describe('updater', () => {
expect((autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature).toBeUndefined()
})
// Why: a prerelease user (e.g. 1.3.17-rc.1) must be able to upgrade to BOTH
// a newer RC (1.3.17-rc.2) and a newer stable (1.3.18). We solve this by
// resolving the newest tag ourselves from the atom feed and pinning the
// generic feed at /releases/download/<tag>/. Using electron-updater's
// native github provider with allowPrerelease would filter out stable
// releases for RC users, trapping them on the RC channel.
// Why: native github provider + allowPrerelease traps RC users on the RC channel, so resolve the newest tag ourselves and pin the generic feed to it.
it('repins the generic feed to the newest RC tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.17-rc.1')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.17-rc.2'])
@@ -2108,9 +2077,7 @@ describe('updater', () => {
})
})
// Why: the original bug in PR #1053 was that RC users couldn't upgrade to a
// newer stable release. The resolver must pick that stable tag for a
// prerelease user so the 'update-available' event fires against it.
// Why: RC users couldn't upgrade to newer stable (PR #1053); resolver must pick the stable tag for a prerelease user.
it('repins the generic feed to a newer stable tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.19'])
@@ -2132,9 +2099,7 @@ describe('updater', () => {
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})
// Why: if the atom-feed resolver fails or finds nothing newer, we must
// fall back to the default /releases/latest/download/ feed so the check
// can still complete and report "not-available" (rather than error out).
// Why: if the atom resolver fails or finds nothing newer, fall back to /releases/latest/download so the check completes as "not-available" instead of erroring.
it('falls back to /releases/latest/download when the atom resolver returns null', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue([])
@@ -3231,9 +3196,7 @@ describe('updater', () => {
})
})
// Why: /releases/latest/download is a moving redirect. If a new stable
// release publishes between check and manual download, a relative ZIP URL
// from the old manifest can resolve against the new release and 404.
// Why: /releases/latest/download is a moving redirect; a relative ZIP URL from an old manifest can resolve against a newer release and 404.
it('pins the generic feed to a concrete stable tag for a stable user', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18'])
@@ -3258,9 +3221,7 @@ describe('updater', () => {
})
})
// Why: Shift-click opts into RC updates, but the native GitHub provider can
// still select cancelled prerelease tags with missing manifests. Keep the
// manifest-probed generic feed path so those tags are skipped.
// Why: native GitHub provider can pick cancelled prerelease tags with missing manifests, so keep the manifest-probed generic feed.
it('uses the manifest-probed generic feed after a Shift-click RC opt-in', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
+53 -187
View File
@@ -42,11 +42,7 @@ type ReleaseFeedPreflightResult = 'ready' | 'not-available'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
// Why: a persistently-failing feed (blocked domain, proxy, GHE mirror) used
// to re-arm the retry at an exact 1h cadence forever — the recurring hourly
// macOS Performance Diagnostics signature in issue #7576. Double the retry
// delay per consecutive failure up to this cap; any completed check resets.
// Release-publishing windows resolve within the first (still 1h) retry.
// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets.
const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000
const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000
const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000
@@ -60,9 +56,7 @@ let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void | Promise<void>) | null = null
let autoUpdaterInitialized = false
// Why: modifier-clicking "Check for Updates" can target prerelease manifests.
// The generic feed still gets pinned to a concrete tag on every check so
// cancelled prereleases without manifests are skipped.
// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
@@ -72,19 +66,14 @@ let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
let quitAndInstallInProgress = false
// Why: once quitAndInstall has committed (Win/Linux install, or macOS with
// Squirrel ready), late autoUpdater 'error' events must not clear
// quittingForUpdate — that would re-enable dock activate mid-installer.
// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer.
let updateInstallCommitted = false
// Why: quit-and-install recovery must only run after the native
// quitAndInstall call. Pre-native cleanup-time autoUpdater errors must not
// clear quittingForUpdate or look like install recovery.
// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery.
let quitAndInstallNativeInvoked = false
let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
let _getLastUpdateCheckAt: (() => number | null) | null = null
let backgroundCheckLaunchPending = false
// Why: a manually promoted background check can emit an error event before the
// paired promise catch runs; keep the promotion attached to that launch.
// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch.
let backgroundCheckPromotedToUserInitiated = false
let updateCheckStallTimer: ReturnType<typeof setTimeout> | null = null
let updateCheckSilentSettleTimer: ReturnType<typeof setTimeout> | null = null
@@ -102,8 +91,7 @@ let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null
let pendingPrereleaseFallback: {
primaryTag: string
fallbackTag: string
// Why: the primary promise cleanup can run after fallback starts; fallback
// events need the attempt-scoped initiation state, not the mutable global.
// Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global.
userInitiated: boolean
suppressedPrimaryPromiseFailureKey: string | null
suppressedPrimaryEventFailure: PrimaryEventSuppression | null
@@ -118,11 +106,9 @@ let _getPendingUpdateNudgeId: (() => string | null) | null = null
let _getDismissedUpdateNudgeId: (() => string | null) | null = null
let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null
let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null
// Why: guards against duplicate download() calls while an accepted request
// transitions the authoritative status to 'downloading'.
// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'.
let downloadInFlight = false
/** Guards against the macOS `activate` handler re-opening the old version
* while Squirrel's ShipIt is replacing the .app bundle. */
/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */
let quittingForUpdate = false
let autoUpdater: ElectronAutoUpdater | null = null
@@ -166,9 +152,7 @@ function getPersistedPendingUpdateNudgeId(): string | null {
}
function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
// Why: only actionable/error states carry the nudge marker so the renderer
// can tell whether a dismiss should also acknowledge the campaign. Cycle-
// boundary states (idle, checking, not-available) never need it.
// Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it.
if (!activeUpdateNudgeId) {
return status
}
@@ -195,8 +179,7 @@ function sendStatus(status: UpdateStatus): void {
if (awaitingNudgeCheckOutcome) {
if (status.state === 'available') {
if (shouldPreserveNudgeForPublishingWindow) {
// Why: a last-good available update is only a temporary fallback; don't
// let dismissing that card consume the newest-release nudge campaign.
// Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign.
deferPendingUpdateNudgeUntilRetry()
} else {
awaitingNudgeCheckOutcome = false
@@ -207,16 +190,10 @@ function sendStatus(status: UpdateStatus): void {
status.state === 'error'
) {
if (shouldPreserveNudgeForPublishingWindow) {
// Why: last-good checks can legitimately say "not available" while
// the campaign's newest release is still publishing.
// Why: last-good checks can say "not available" while the campaign's newest release is still publishing.
deferPendingUpdateNudgeUntilRetry()
} else {
// Why: when a nudge-triggered check finds no update (or errors out),
// move the campaign to dismissed so it doesn't re-fire on the next
// poll cycle. Without this, a nudge whose version range includes
// already-up-to-date users would loop every 30 minutes, each time
// triggering a redundant checkForUpdates() and clearing the persisted
// dismissedUpdateVersion.
// Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll.
if (activeUpdateNudgeId) {
_setDismissedUpdateNudgeId?.(activeUpdateNudgeId)
}
@@ -240,8 +217,7 @@ function sendStatus(status: UpdateStatus): void {
clearPublishingWindowLastGoodCheck()
}
// Why: reset the in-flight guard when the status moves past the
// window where duplicate download() calls are possible.
// Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible.
if (
decoratedStatus.state === 'downloading' ||
decoratedStatus.state === 'error' ||
@@ -284,9 +260,7 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant
function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
pendingUserInitiatedCheckAfterInFlight = null
setTimeout(() => {
// Why: electron-updater clears its in-flight promise after emitting the
// terminal event. Deferring one tick lets the queued modifier check start
// fresh instead of being deduped into the just-finished stable check.
// Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one.
if (currentStatus.state === 'checking') {
currentStatus = { state: 'idle' }
}
@@ -405,8 +379,7 @@ function beginUpdateCheckAttempt(): number {
updateCheckAttemptSequence += 1
activeUpdateCheckAttemptId = updateCheckAttemptSequence
armUpdateCheckStallTimer(activeUpdateCheckAttemptId)
// Why: issue #7576's warnings recurred at the retry cadence; field captures
// need a timestamp for each check attempt to confirm or rule the updater out.
// Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater.
writeMainThreadDiagnosticMarker('updater-check-attempt')
return activeUpdateCheckAttemptId
}
@@ -444,8 +417,7 @@ function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean
const shouldRetrySoon = consumeSilentCheckShortRetryReason()
clearAvailableUpdateContext()
if (shouldRetrySoon) {
// Why: a silent result against a temporary last-good feed is still part of
// a release transition, so it must not suppress the short publish retry.
// Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
return true
}
@@ -493,9 +465,7 @@ function handleSettledUpdateCheckPromise(attemptId: number): void {
return
}
clearUpdateCheckSilentSettleTimer()
// Why: electron-updater can resolve its promise before the terminal event
// reaches our handlers. Give that event a short grace period, then unstick
// checks that genuinely resolved without one.
// Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one.
updateCheckSilentSettleTimer = setTimeout(() => {
updateCheckSilentSettleTimer = null
settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated())
@@ -506,8 +476,7 @@ function shouldHandleUpdaterErrorEvent(): boolean {
if (getActiveUpdateCheckEventAttemptId() !== null) {
return true
}
// Why: electron-updater emits check errors globally. Once a check has
// settled, only active download/install flows should keep consuming errors.
// Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them.
return (
downloadInFlight ||
currentStatus.state === 'downloading' ||
@@ -523,8 +492,7 @@ function sendErrorStatus(message: string, userInitiated?: boolean): void {
) {
return
}
// Why: counts AV/EDR-blocked Windows signature checks in the field so we can
// size the affected cohort before investing in bigger updater changes.
// Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes.
if (isWindowsSignatureCheckUnavailableFailure(message)) {
recordUpdaterLifecycle('windows_signature_check_blocked', undefined, {
level: 'warn',
@@ -582,12 +550,7 @@ async function performQuitAndInstall(): Promise<void> {
markMacQuitAndInstallInFlight()
// Set this BEFORE anything else so the `activate` handler in index.ts
// won't re-open the old version while Squirrel's ShipIt is replacing
// the .app bundle. Without this guard the quit triggers window
// destruction → BrowserWindow.getAllWindows().length === 0 → activate
// fires → openMainWindow() resurrects the old process and ShipIt
// either can't replace it or the user ends up on the old version.
// Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle.
quittingForUpdate = true
const pendingVersion = getPendingInstallVersion()
@@ -610,23 +573,17 @@ async function performQuitAndInstall(): Promise<void> {
recordUpdaterLifecycle('quit_and_install_invoking_native', {
version: pendingVersion || null
})
// Why: defensive — state should stay in-progress until native invoke, but
// never call quitAndInstall if recovery/reset already cleared the handoff.
// Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff.
if (!quitAndInstallInProgress) {
return
}
// Why: mark before the call so a sync 'error' during quitAndInstall can
// recover; pre-native errors must not look like install failure.
// Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure.
quitAndInstallNativeInvoked = true
// Why: invoke quitAndInstall before killAllPty/remove close listeners so a
// sync 'error' (common "no filepath" path) recovers while windows and
// local PTYs are still intact.
// Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact.
getAutoUpdater().quitAndInstall(false, true)
span.addEvent('native_quit_and_install_invoked')
// Why: handleQuitAndInstallFailure may clear quitAndInstallInProgress
// synchronously during quitAndInstall (Win/Linux dispatchError). Skip
// destructive prep if recovery already ran.
// Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran.
if (!quitAndInstallInProgress) {
return
}
@@ -641,15 +598,10 @@ async function performQuitAndInstall(): Promise<void> {
windowCount: BrowserWindow.getAllWindows().length
})
// Why: committed installs must keep quittingForUpdate true so dock
// activate cannot reopen the old process mid-ShipIt/installer. macOS
// without Squirrel ready stays uncommitted so late native errors can
// still recover flags (PTYs may already be dead — residual OK).
// Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover.
if (process.platform !== 'darwin' || isMacInstallerReady()) {
updateInstallCommitted = true
// Why: past this point recovery is forbidden and the installer waits
// for this process to exit; a wedged async shutdown would otherwise
// strand the user with no app and no update (#4438).
// Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438).
armUpdateInstallExitWatchdog()
}
})
@@ -678,12 +630,7 @@ function resetQuitForUpdateState(): void {
resetMacInstallState()
}
// Why: electron-updater often reports quitAndInstall failures via the 'error'
// event. On Win/Linux this is frequently synchronous (dispatchError inside
// install()); on macOS/spawn it can be async. Recover only after native invoke
// and only when install has not been committed — after commit, clearing
// quittingForUpdate would allow dock activate to reopen the old process
// mid-installer.
// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer.
function handleQuitAndInstallFailure(): boolean {
if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) {
return false
@@ -697,8 +644,7 @@ function handleQuitAndInstallFailure(): boolean {
return true
}
// Why: while quit-and-install owns the process (pre-native cleanup through
// post-commit handoff), general check/download error UI must not run.
// Why: while quit-and-install owns the process, general check/download error UI must not run.
function isQuitAndInstallHandoffActive(): boolean {
return quitAndInstallInProgress
}
@@ -785,27 +731,16 @@ async function sendCheckFailureStatus(
const handleFailure = async (): Promise<void> => {
if (isBenignCheckFailure(message)) {
// Why: release transition failures (missing latest.yml while a new
// release is being published) and network blips are transient. Schedule
// a background retry so the notification arrives once the release
// finishes, and intentionally skip persistLastUpdateCheckAt — the check
// didn't truly complete, and recording a timestamp would suppress the
// next startup check.
// Why: benign failures (publishing latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check).
console.warn('[updater] benign check failure:', message)
clearAvailableUpdateContext()
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
if (userInitiated) {
// Why: a user-initiated click expects visible feedback — silently
// dropping to 'idle' makes the button look broken. The card already
// prefixes "Could not check for updates." and Settings prefixes
// "Update check failed.", so the message here only carries the
// actionable cause.
// Why: a user click needs visible feedback (idle looks broken); the UI already prefixes context, so this carries only the actionable cause.
sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true)
} else {
if (isReleaseAssetsPublishingFailure(message)) {
// Why: a nudge-triggered check can land during the brief window where
// GitHub exposes a release before its updater assets are reachable.
// Keep the campaign pending so the short retry can still show it.
// Why: a nudge check can land while GitHub exposes a release before its assets; keep the campaign pending so the short retry can show it.
deferPendingUpdateNudgeUntilRetry()
}
sendStatus({ state: 'idle' })
@@ -839,9 +774,7 @@ let consecutiveAutomaticRetrySchedules = 0
function scheduleAutomaticUpdateCheck(delayMs: number): void {
let effectiveDelayMs = delayMs
// All retry-cadence callers (here and updater-events) pass exactly this
// constant, so keying the backoff on it keeps one choke point instead of
// threading a flag through seven schedule sites.
// All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site.
if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) {
effectiveDelayMs = Math.min(
AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules,
@@ -853,10 +786,7 @@ function scheduleAutomaticUpdateCheck(delayMs: number): void {
clearTimeout(autoUpdateCheckTimer)
}
autoUpdateCheckTimer = setTimeout(() => {
// Why: Orca is often left running for days. A one-shot startup check means
// users can miss fresh releases entirely, so we always keep the next
// background attempt scheduled in the main process instead of tying checks
// to relaunches or renderer lifetime.
// Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime.
runBackgroundUpdateCheck()
}, effectiveDelayMs)
}
@@ -923,8 +853,7 @@ function shouldSuppressMissingManifestPrereleaseFallbackEvent(
const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure
if (primaryEventSuppression?.failureKey === failureKey) {
const isPrimaryPromisePair = primaryEventSuppression.error === error
// Why: after fallback checking starts, same-message errors may belong to
// the fallback attempt, so message matching alone is not safe.
// Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe.
if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) {
pendingPrereleaseFallback.suppressedPrimaryEventFailure = null
clearPrereleaseFallbackContextIfSettled()
@@ -956,12 +885,7 @@ async function pinDefaultReleaseFeed(
variant: UpdateCheckVariant = 'default'
): Promise<ReleaseFeedPreflightResult> {
const autoUpdater = getAutoUpdater()
// Why: the /releases/latest/download/ redirect can move between the update
// check and the later manual download click. Pinning to the concrete tag
// keeps the manifest and ZIP asset on the same release.
//
// Prerelease users still need any-channel resolution so they can move to a
// newer RC or the next stable. Stable users should only resolve stable tags.
// Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable).
const currentVersion = app.getVersion()
const isPerfCheck = variant === 'perf'
const includePrerelease =
@@ -991,10 +915,7 @@ async function pinDefaultReleaseFeed(
retryLaunched: false
}
: null
// Why: console.info goes to stdout and is captured by Console.app on macOS
// and by --enable-logging elsewhere. This is the only window we have into
// the updater on a user's machine when something goes wrong. Cheap to keep,
// invaluable when triaging.
// Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater.
if (newerTag) {
clearPublishingWindowLastGoodCheck()
const url = getReleaseDownloadUrl(newerTag)
@@ -1006,8 +927,7 @@ async function pinDefaultReleaseFeed(
} else if (releaseTagsResult.state === 'not-ready') {
clearPrereleaseFallbackContext()
if (releaseTagsResult.lastGoodTag) {
// Why: during a publish window the newest tag is unsafe, but a verified
// last-good concrete feed lets electron-updater emit a real result.
// Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result.
const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag)
console.info(
`[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease}${url}`
@@ -1062,9 +982,7 @@ function retryPrereleaseFallbackAfterMissingManifest(
return false
}
// Why: a published tag can briefly point at a missing platform manifest
// during GitHub release transitions. Walk back once to the previous feed
// entry so users on the last good build see a normal not-available result.
// Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result.
pendingPrereleaseFallback.retryLaunched = true
pendingPrereleaseFallback.userInitiated = Boolean(userInitiated)
pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey =
@@ -1113,21 +1031,13 @@ function runBackgroundUpdateCheck(
sendStatus({ state: 'not-available' })
return
}
// Why: scope the nudge marker to the updater cycle being launched right now.
// Setting it here, before any updater events or rejected promises can arrive,
// prevents later ordinary checks from inheriting an older campaign id. Use
// the persisted pending id for ordinary background checks so a nudge-driven
// card can still be dismissed correctly after relaunch or a later 24h check.
// Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch.
activeUpdateNudgeId = nudgeId
// Why: autoUpdater.checkForUpdates() is async and 'checking-for-update'
// arrives on a later tick, so a second focus/resume event can slip in before
// currentStatus flips to 'checking'. Track the launch in memory to dedupe
// that gap without persisting a successful-check timestamp before the result.
// Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap.
backgroundCheckLaunchPending = true
backgroundCheckPromotedToUserInitiated = false
const attemptId = beginUpdateCheckAttempt()
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
// and sending it from both places causes duplicate notifications (issue #35).
// Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35).
const autoUpdater = getAutoUpdater()
const launch = (): Promise<unknown> | undefined => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
@@ -1154,18 +1064,7 @@ function runBackgroundUpdateCheck(
}
export function checkForUpdates(): void {
// Fire-and-forget the span so the public function signature stays
// synchronous (callers do not await this). The span ALWAYS records
// Success — it captures only the launch of the check, not its outcome.
// The actual check runs through autoUpdater event handlers; failure is
// surfaced via sendCheckFailureStatus on a separate code path.
// Dashboards: do not group on this span's outcome attribute — the
// success rate here reflects launch dispatch, not check success, and
// will read ~100% by construction. Instead, filter on
// `updater.outcome === 'launched'` to count check-launch dispatches; the
// attribute makes the always-success semantics explicit and queryable
// (so a dashboard tile can't accidentally treat this span's success rate
// as the actual update-check success rate).
// Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate.
void withUpdaterSpan({ stage: 'check' }, async (span) => {
span.setAttribute('updater.outcome', 'launched')
runBackgroundUpdateCheck()
@@ -1180,10 +1079,7 @@ function enableIncludePrerelease(): void {
if (includePrereleaseActive) {
return
}
// Why: generic-provider checks still need this flag so electron-updater will
// accept a prerelease manifest for users who intentionally Shift-clicked.
// We keep using the manifest-probed generic feed instead of the native
// GitHub provider because cancelled RC releases can appear without assets.
// Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets.
enablePrereleaseManifestChecks()
includePrereleaseActive = true
}
@@ -1201,27 +1097,21 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
enableIncludePrerelease()
} else if (checkVariant === 'perf') {
clearPrereleaseFallbackContext()
// Why: perf checks need prerelease manifests for this check, but must not
// opt future default/background checks into the RC channel.
// Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel.
enablePrereleaseManifestChecks()
}
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
userInitiatedCheck = true
// Why: a manual check is independent of any active nudge campaign. Reset the
// nudge marker so the resulting status is not decorated with activeNudgeId,
// which would cause a later dismiss to consume the campaign by accident.
// Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident.
activeUpdateNudgeId = null
// Why: manual checks should visibly respond before feed pinning or the
// electron-updater event fires; duplicate event broadcasts are suppressed by
// status equality below.
// Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below.
sendStatus({ state: 'checking', userInitiated: true })
if (checkAlreadyInFlight) {
backgroundCheckPromotedToUserInitiated = true
rearmActiveUpdateCheckStallTimer()
if (checkVariant !== 'default') {
// Why: the in-flight check may have already pinned the stable feed. Queue
// a fresh modifier check so it doesn't inherit a stale-channel result.
// Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result.
pendingUserInitiatedCheckAfterInFlight = checkVariant
}
return
@@ -1285,10 +1175,7 @@ export function quitAndInstall(): void {
return
}
// Why: every renderer entrypoint reaches this IPC handler from an in-flight
// click or toast callback. Deferring the actual quit here gives the renderer
// a moment to flush dismissals/state updates before windows start closing,
// and centralizing it avoids drift between the toast flow and settings UI.
// Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing.
pendingQuitAndInstallTimer = setTimeout(() => {
void performQuitAndInstall()
}, QUIT_AND_INSTALL_DELAY_MS)
@@ -1391,12 +1278,7 @@ export function setupAutoUpdater(
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Why: the only on-machine window we have into electron-updater. Without
// this, an unexpected `update-not-available` (e.g. RC user not offered
// newer stable) is invisible — we can't tell whether the manifest fetch
// got the wrong version, the request failed, or a stale in-flight check
// was deduped. Logs go to main-process stdout, captured on macOS by
// Console.app under the app bundle, and on Win/Linux by --enable-logging.
// Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible.
autoUpdater.logger = {
info: (m: unknown) => console.info('[autoUpdater]', m),
warn: (m: unknown) => console.warn('[autoUpdater]', m),
@@ -1404,21 +1286,9 @@ export function setupAutoUpdater(
debug: (m: unknown) => console.debug('[autoUpdater]', m)
} as never
// Why: Windows update integrity is enforced by electron-updater's built-in
// Authenticode check against the `publisherName` (SignPath Foundation) that
// electron-builder embeds in app-update.yml. Do NOT re-add a
// `verifyUpdateCodeSignature` override — a no-op override silently accepts
// every downloaded installer, disabling signature verification entirely.
// Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer.
// Use the generic provider with GitHub's /releases/latest/download/ URL as
// the startup fallback so electron-updater can fetch the manifest
// (latest-mac.yml, latest.yml, latest-linux.yml) from the latest
// non-prerelease release.
//
// Why: before each default-channel check we repin this URL to a concrete
// /releases/download/<tag>/ URL. Keeping the generic provider avoids the
// native GitHub provider's RC channel filtering, and pinning avoids the
// moving /latest redirect changing between check and download.
// Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download/<tag>/ URL avoids /latest redirect drift between check and download.
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
@@ -1508,10 +1378,7 @@ export function downloadUpdate(): void {
if (downloadInFlight) {
return
}
// Why: permit retry from 'error' when we still have a cached availableVersion —
// a failed download leaves the status at 'error' but availableVersion intact,
// and the error card's "Retry Download" button must be able to restart the
// download. Without this, the button would appear to do nothing.
// Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works.
const canStart =
currentStatus.state === 'available' ||
(currentStatus.state === 'error' && hasNewerDownloadedVersion())
@@ -1524,8 +1391,7 @@ export function downloadUpdate(): void {
}
downloadInFlight = true
beginMacUpdateDownload()
// Why: retries may spend seconds in setup before electron-updater emits
// progress; surface acceptance immediately so the action never looks inert.
// Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert.
sendStatus({ state: 'downloading', percent: 0, version })
getAutoUpdater()
.downloadUpdate()
+16 -57
View File
@@ -50,10 +50,7 @@ import { logStartupMilestone } from '../startup/startup-diagnostics'
const UPDATER_SETUP_FALLBACK_MS = 15_000
// Why: updater setup is deferred past first paint, but a manual check (app
// menu or updater:check IPC) can arrive inside that window — it must run
// against a configured updater (listeners, autoDownload=false, window ref),
// so those entry points force the pending setup first.
// Why: a manual check can arrive before deferred setup runs, so entry points force this pending setup to configure the updater first.
let pendingAutoUpdaterSetup: (() => void) | null = null
export function ensureAutoUpdaterConfigured(): void {
@@ -102,28 +99,13 @@ export function attachMainWindowServices(
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight
}
)
// Why: the Manage Sessions settings panel (docs/daemon-staleness-ux.md §Phase 1)
// uses a narrow `pty:management:*` IPC surface that reads the live
// DaemonPtyRouter via getDaemonProvider(). Registering here — after
// registerPtyHandlers — keeps this wiring alongside the rest of the PTY IPC
// and ensures the handlers are re-installed on macOS app re-activation when
// the main window is recreated.
// Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1).
registerDaemonManagementHandlers()
// Why: do not enumerate repo paths from background GC. `git worktree list`
// can re-touch protected folders on macOS and trigger folder-access prompts.
// Why: don't enumerate repo paths in background GC `git worktree list` can touch protected macOS folders and trigger access prompts.
scheduleHistoryGc(async () => {
return getKnownWorktreeIdsForHistoryGc(store)
})
// Why: warm-reattach gap.
// Daemon-hosted PTYs survive renderer restarts on purpose, so on a fresh
// Orca launch the daemon's `listSessions()` returns sessions that
// `pty:spawn` hasn't re-registered yet. Without this hydration, the
// memory snapshot omits those PTYs and the renderer mislabels their
// workspaces as `· REMOTE` while showing `—` for CPU/Memory.
// `hydrateLocalPtyRegistryAtBoot` is idempotent (no-op after the first
// call), so calling it on every macOS dock re-activation — when this
// function re-runs as the main window is recreated — does not redo the
// git I/O or daemon RPC.
// Why: daemon PTYs survive renderer restarts, so at boot they're unregistered; hydrate so they aren't mislabeled REMOTE (idempotent, safe to re-run).
void hydrateLocalPtyRegistryAtBoot(store)
const localPtyStartupReady = options?.awaitLocalPtyStartup?.()
if (localPtyStartupReady) {
@@ -139,11 +121,7 @@ export function attachMainWindowServices(
registerSshHandlers(store, () => mainWindow, runtime)
registerRemoteWorkspaceHandlers(store, () => mainWindow)
registerFileDropRelay(mainWindow)
// Why: setupAutoUpdater's first getAutoUpdater() call synchronously
// require()s electron-updater in packaged builds — seconds on a cold
// Windows disk under Defender scanning (part of issue #7225's pre-paint
// stall) — so defer it past first paint. The timer fallback keeps update
// checks alive for renderers that crash-loop before ever painting.
// Why: setupAutoUpdater sync-require()s electron-updater (slow on cold Windows w/ Defender, #7225), so defer past first paint; timer fallback covers crash-looping renderers.
let updaterSetupDone = false
const setupAutoUpdaterDeferred = (): void => {
if (updaterSetupDone || mainWindow.isDestroyed()) {
@@ -165,12 +143,7 @@ export function attachMainWindowServices(
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
setPendingUpdateNudgeId: (id) => {
// Why: the nudge lifecycle is owned by the main process. When applying a
// new campaign, persist the pending id AND clear the version dismissal
// together so relaunches cannot resurrect the old hidden-card state
// between nudge apply and renderer sync. When clearing (id is null),
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
// would silently un-dismiss an update if the flow ever changes.
// Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses.
if (id) {
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
} else {
@@ -212,9 +185,7 @@ export function attachMainWindowServices(
)
mainWindow.on('closed', () => {
// Why: browser webviews are renderer-owned guest surfaces. Clearing
// main-owned guest registrations on window close prevents stale
// tab→webContents ids from leaking across app relaunch or hot-reload cycles.
// Why: clear main-owned guest registrations on close so stale tab→webContents ids don't leak across relaunch/hot-reload.
browserManager.unregisterAll()
})
}
@@ -223,8 +194,7 @@ function registerAppReloadHandler(
mainWindow: BrowserWindow,
onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void
): void {
// Why: the process-global IPC handler can outlive the BrowserWindow, so keep
// the registered WebContents and guard both lifetimes before using it.
// Why: the process-global IPC handler can outlive the window, so guard both lifetimes before using the WebContents.
const handlerToken = ++appReloadHandlerTokenCounter
activeAppReloadHandlerToken = handlerToken
const mainWebContents = mainWindow.webContents
@@ -244,8 +214,7 @@ function registerAppReloadHandler(
if (activeAppReloadHandlerToken !== handlerToken) {
return
}
// Why: macOS can keep the process alive with no window, and this global
// handler otherwise keeps the closed BrowserWindow reachable until reopen.
// Why: macOS keeps the process alive with no window; this handler would otherwise retain the closed window until reopen.
ipcMain.removeHandler('app:reload')
activeAppReloadHandlerToken = null
})
@@ -265,8 +234,7 @@ function registerRuntimeWindowLifecycle(
}
runtime.setNotifier({
worktreesChanged: (repoId, renamed) => {
// Why: clear detected-worktree scan caches before renderer listeners
// handle this event, preventing stale TTL reads after mutations.
// Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation.
runWorktreeChangeInvalidators(repoId)
send('worktrees:changed', renamed ? { repoId, renamed } : { repoId })
},
@@ -308,8 +276,7 @@ function registerRuntimeWindowLifecycle(
event: Electron.IpcMainEvent,
reply: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
// Why: requestId is renderer-supplied; only the targeted main window
// may satisfy the reveal and provide the tab handle.
// Why: requestId is renderer-supplied, so only the targeted main window may satisfy the reveal.
if (event.sender !== mainWindow.webContents || reply.requestId !== requestId) {
return
}
@@ -334,9 +301,7 @@ function registerRuntimeWindowLifecycle(
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
activate: opts.activate !== false,
...(opts.presentation ? { presentation: opts.presentation } : {}),
// Why: pre-minted tabId from main keeps the renderer's tab id aligned
// with the paneKey baked into the PTY env at spawn time, so hook
// events route to the right slot.
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
...(opts.splitFromLeafId !== undefined ? { splitFromLeafId: opts.splitFromLeafId } : {}),
@@ -402,18 +367,14 @@ function registerRuntimeWindowLifecycle(
browserDriverChanged: (browserPageId, driver) =>
send('runtime:browserDriverChanged', { browserPageId, driver })
})
// Why: the runtime must fail closed while the renderer graph is being torn
// down or rebuilt, otherwise future CLI calls could act on stale terminal
// mappings during reload transitions.
// Why: fail closed during renderer reload so CLI calls can't act on stale terminal mappings.
mainWindow.webContents.on('did-start-loading', () => {
runtime.markRendererReloading(mainWindow.id)
})
mainWindow.on('closed', () => {
runtime.markGraphUnavailable(mainWindow.id)
if (activeRuntimeNotifierToken === notifierToken) {
// Why: the notifier closes over the BrowserWindow for mobile/CLI UI
// relays; clear it during the no-window gap so the runtime does not
// retain destroyed window graphs.
// Why: the notifier closes over the window; clear it in the no-window gap so the runtime can't retain destroyed graphs.
runtime.setNotifier(null)
activeRuntimeNotifierToken = null
}
@@ -436,14 +397,12 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void {
return
}
// Why: relay exactly one IPC event per drop gesture so the renderer
// receives the full batch of paths without timer-based reconstruction.
// Why: one IPC event per drop gesture so the renderer gets the full path batch without timer-based reconstruction.
mainWindow.webContents.send('terminal:file-drop', args)
}
ipcMain.on(channel, relayFileDrop)
mainWindow.on('closed', () => {
// Why: macOS can keep the app process alive after the window closes; drop
// the relay closure so a destroyed BrowserWindow is not retained.
// Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained.
ipcMain.removeListener(channel, relayFileDrop)
})
}
+83 -283
View File
@@ -50,13 +50,11 @@ import { buildEditableContextMenuTemplate } from './editable-context-menu'
import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui'
import { resolveWindowCloseAction } from './window-close-decision'
// Why: show/restore/resume can overlap before the size nudge resets; never
// capture the temporary width as the next repaint's baseline.
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
const activeRepaintJiggles = new WeakSet<BrowserWindow>()
function forceRepaint(window: BrowserWindow): void {
// Why: webContents can be destroyed a beat before the BrowserWindow during
// close, and this runs from timers/focus events that can land in that gap.
// Why: webContents can be destroyed a beat before the BrowserWindow during close, and this runs from timers/focus events in that gap.
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
}
@@ -82,8 +80,7 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
if (delayedRepaintTimer) {
clearTimeout(delayedRepaintTimer)
}
// Why: macOS can finish restoring webview compositor layers after Electron's
// show/restore event, so a second paint catches late black-surface recovery.
// Why: macOS may restore compositor layers after the show/restore event; a second paint catches late black-surface recovery.
delayedRepaintTimer = setTimeout(() => {
delayedRepaintTimer = null
forceRepaint(window)
@@ -98,9 +95,7 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
window.on('restore', repaintAfterVisibilityTransition)
window.on('show', repaintAfterVisibilityTransition)
// Why: occlusion-uncover fires neither restore nor show; focus is the only
// signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal
// on each Cmd+Tab.
// Why: occlusion-uncover fires no restore/show, only focus; invalidate only — the setSize jiggle would SIGWINCH every terminal on Cmd+Tab.
window.on('focus', () => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.invalidate()
@@ -121,10 +116,7 @@ function isMacAppPasteInput(input: Electron.Input): boolean {
)
}
// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~18 CSS px from the top.
// At zoom factor z that becomes 18·z window px. Traffic lights are
// ~12px tall, so we position their top edge at (center 6).
// Why: titlebar content center sits ~18 CSS px from top (×zoom); traffic lights are ~12px tall, so top edge = center 6.
const TITLEBAR_CSS_CENTER = 18
const TRAFFIC_LIGHT_RADIUS = 6
const TRAFFIC_LIGHT_X = 16
@@ -140,44 +132,31 @@ function syncTrafficLightPosition(win: BrowserWindow, zoomFactor: number): void
}
type CreateMainWindowOptions = {
/** Returns true when a manual app.quit() (Cmd+Q) is in progress. The close
* handler sends this to the renderer so it can skip the running-process
* confirmation dialog and proceed directly to buffer capture + close. */
/** Returns true when a manual app.quit() (Cmd+Q) is in progress, so the renderer skips the running-process confirm dialog. */
getIsQuitting?: () => boolean
/** Notifies the caller when the renderer vetoes unload. Why: a prevented
* beforeunload cancels the in-flight app.quit(), so the app-level quit
* latch must be cleared or later window closes will be misclassified as
* quit attempts. */
/** Notifies the caller when the renderer vetoes unload, so the quit latch clears — a prevented beforeunload cancels the in-flight app.quit(). */
onQuitAborted?: () => void
onRendererProcessGone?: (
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => void
/** Returns true when Orca should reload after an unexpected renderer loss.
* Why: update relaunch and app quit intentionally tear down child
* processes; recovering those paths can fight Electron's shutdown. */
/** Returns true when Orca should reload after renderer loss; update-relaunch/quit tear down children intentionally, so don't fight shutdown. */
shouldRecoverRenderer?: (
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => boolean
/** Called when consecutive auto-recoveries hit the circuit-breaker limit, so
* the host can record diagnostics and surface a recovery prompt instead of
* letting Orca crash-loop. */
/** Called when consecutive auto-recoveries hit the circuit-breaker limit so the host can prompt instead of crash-looping. */
onRendererRecoveryExhausted?: (info: {
details: Electron.RenderProcessGoneDetails
webContentsId: number
recentRecoveryCount: number
}) => void
/** Why: main-process startup must register IPC handlers before the renderer
* begins booting, or eager renderer calls can race into missing channels. */
/** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */
deferLoad?: boolean
title?: string
getKeybindings?: () => KeybindingOverrides | undefined
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
/** Why: the in-place renderer-recovery reload re-fires did-finish-load, whose
* local-PTY orphan sweep would kill live sessions across the single window
* before session restore re-attaches them (#5787). This callback lets the host
* mark that one reload so the sweep can be skipped for it. */
/** Marks the in-place recovery reload so did-finish-load's PTY orphan sweep spares live sessions until restore re-attaches (#5787). */
onBeforeRecoveryReload?: (webContentsId: number) => void
}
@@ -194,19 +173,8 @@ export function createMainWindow(
opts?: CreateMainWindowOptions
): BrowserWindow {
const rawSavedBounds = store?.getUI().windowBounds
// Why: defense in depth — if a previous quit/update path persisted
// shrink-to-min bounds (see freezeBoundsOnQuit), discard them on restore
// rather than resurrecting a tiny window. Anything at or below the min
// dimensions is treated as corrupt and falls back to defaultBounds. The
// position must also land on a currently-attached display with a
// *meaningful* visible area — not just any >0 overlap, since a 1-pixel
// sliver (or a sub-pixel shaving after DPI scaling) would still leave
// the titlebar unreachable. Require at least MIN_WIDTH/2 of horizontal
// and MIN_HEIGHT/2 of vertical overlap with some display's workArea
// (workArea excludes menu bar / dock, so a rect entirely hidden under
// the dock is also correctly discarded). A rect saved while an external
// monitor was connected would otherwise be restored off-screen and
// macOS would silently shrink/reposition the window.
// Why: discard persisted bounds that are shrink-to-min (corrupt, see freezeBoundsOnQuit) or off-screen, falling back to defaultBounds.
// Require ~half MIN_WIDTH/HEIGHT of workArea overlap so a 1px sliver or dock-hidden rect isn't treated as visible.
const rectHasVisibleAreaOnAnyDisplay = (b: {
x: number
y: number
@@ -242,9 +210,7 @@ export function createMainWindow(
)
}
const savedMaximized = store?.getUI().windowMaximized ?? false
// Why: on first launch (no saved bounds), fill the primary display work area
// so the window feels spacious without calling maximize(). Saved bounds still
// win on subsequent launches.
// Why: on first launch fill the primary display work area so the window feels spacious without maximize(); saved bounds win later.
const defaultBounds = (() => {
try {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
@@ -256,16 +222,11 @@ export function createMainWindow(
const settings = store?.getSettings()
browserManager.setDictationShortcutForwardingPredicate(() => {
// Why: focused webview guests do not expose a safe transcript insertion
// target yet. Let Cmd/Ctrl+E continue to the page instead of starting a
// dictation session whose final text would be dropped.
// Why: webview guests expose no safe transcript insertion target; let Cmd/Ctrl+E reach the page instead of dropping dictation text.
return false
})
const blur = settings?.windowBackgroundBlur ?? false
// Why: native blur requires platform-specific Electron APIs. macOS uses
// vibrancy (needs transparent: true), Windows uses backgroundMaterial.
// Linux has no native equivalent. Blur only applies at window creation;
// changing the setting requires a restart.
// Why: blur uses platform APIs (macOS vibrancy+transparent, Windows backgroundMaterial, Linux none) and only applies at creation, needs restart.
const platformBlurOptions = blur
? process.platform === 'darwin'
? { vibrancy: 'under-window' as const, transparent: true }
@@ -282,37 +243,21 @@ export function createMainWindow(
minHeight: MIN_HEIGHT,
title: opts?.title ?? 'Orca',
show: false,
// Why: macOS swallows the app-activating click by default, so clicking
// back into Orca (e.g. the floating workspace) needed a second click.
// macOS-only option; Windows/Linux already deliver that click.
// Why: macOS swallows the app-activating click by default, so clicking back into Orca needed a second click (Windows/Linux already deliver it).
acceptFirstMouse: true,
// Why: on macOS the menu lives in the system menu bar, so the in-window
// menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar
// doesn't consume a dedicated row of vertical space on every launch —
// users can still reveal the (properly restructured) File/Edit/View/
// Window/Help menus by pressing Alt, matching native Windows/Linux
// conventions (File Explorer, Firefox, etc.).
// Why: auto-hide the Windows/Linux menu bar to save a row (Alt reveals it); macOS uses the system menu bar anyway.
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
// Why: on macOS 'hiddenInset' keeps the native traffic lights positioned
// inside our custom 42px titlebar. On Windows 'hidden' removes the default
// OS title bar (which would otherwise stack on top of our renderer titlebar
// and waste vertical space) while still allowing our renderer to draw its
// own drag region and window controls.
// Why: macOS 'hiddenInset' keeps native traffic lights in our custom titlebar; Windows 'hidden' removes the OS title bar so it doesn't double up.
titleBarStyle:
process.platform === 'darwin'
? 'hiddenInset'
: process.platform === 'win32'
? 'hidden'
: undefined,
// Why: Linux ignores titleBarStyle: 'hidden', so without this the native
// WM title bar stays and stacks on top of our renderer titlebar (double
// title bar). frame: false drops the native frame; the renderer draws its
// own titlebar + window controls (see WindowControls in App.tsx), matching
// the Windows custom-titlebar path.
// Why: Linux ignores titleBarStyle 'hidden'; frame:false drops the native frame so we don't get a double title bar (renderer draws its own).
...(process.platform === 'linux' ? { frame: false } : {}),
// Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts
// dynamically when the user changes UI zoom.
// Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts on zoom change.
...(process.platform === 'darwin'
? {
trafficLightPosition: {
@@ -330,24 +275,16 @@ export function createMainWindow(
}
})
const rendererWebContentsId = mainWindow.webContents.id
// Why: native paste fallback is privileged IPC; only the real top-level
// renderer should be allowed to request Electron's native paste operation.
// Why: native paste fallback is privileged IPC; only the top-level renderer may request it.
setTrustedUIRendererWebContentsId(rendererWebContentsId)
if (process.platform === 'darwin') {
// Why: browser-guest surfaces are kept alive by their own per-guest
// unthrottle (browser-manager attach), so the main window can throttle
// normally while hidden/occluded instead of rendering at full rate.
// Toggling must happen only while visible: flipping it on a hidden window
// desyncs Chromium's frame evictor and blanks the surface (electron#42378).
// Why: throttle the main window while hidden (guests self-unthrottle); toggle only while visible or Chromium blanks the surface (electron#42378).
mainWindow.webContents.setBackgroundThrottling(true)
installMacosVisibilityRepaint(mainWindow)
}
// Why: a focus-preserving system/display wake fires no window focus or
// visibility events in the renderer, so terminal wake recovery would never
// run. Relay powerMonitor resume explicitly (supported on mac/win/linux)
// and force a repaint so stale compositor surfaces recover too.
// Why: a focus-preserving wake fires no focus/visibility events; relay resume so terminal wake recovery runs and force a repaint so stale compositor surfaces recover.
const onSystemResume = (): void => {
if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) {
return
@@ -360,25 +297,18 @@ export function createMainWindow(
mainWindow.webContents.on('dom-ready', () => {
const level = store?.getUI().uiZoomLevel ?? 0
mainWindow.webContents.setZoomLevel(level)
// Why: the native traffic lights sit at a fixed position in the window
// while CSS content scales with zoom. We must reposition the buttons
// on startup so they stay vertically aligned with the zoomed titlebar.
// Why: native traffic lights don't scale with CSS zoom; reposition on startup to stay aligned with the zoomed titlebar.
if (process.platform === 'darwin') {
syncTrafficLightPosition(mainWindow, Math.pow(1.2, level))
}
})
// Why: on macOS + Electron 41, creating a webview guest process can re-emit
// ready-to-show on the same BrowserWindow. Without a one-shot guard the
// handler re-runs maximize() from the persisted savedMaximized flag, snapping
// the window back to full-screen after the user already resized it (#591).
// Why: macOS+Electron 41 re-emits ready-to-show on webview-guest creation; a one-shot guard stops re-running maximize() after resize (#591).
let handledInitialReadyToShow = false
let initialRevealFallbackTimer: ReturnType<typeof setTimeout> | null =
process.platform === 'win32' || process.platform === 'linux'
? setTimeout(() => {
// Why: GPU/driver failures on Windows and Linux/X11 can prevent
// ready-to-show forever, leaving the only app window hidden while the
// main process stays alive (#8421).
// Why: GPU/driver failures on Windows/Linux can prevent ready-to-show forever, hiding the only app window (#8421).
initialRevealFallbackTimer = null
revealInitialWindow()
}, 10_000)
@@ -403,9 +333,7 @@ export function createMainWindow(
handledInitialReadyToShow = true
clearInitialRevealFallbackTimer()
// Why: in E2E headless mode, the window stays hidden to avoid stealing
// focus and screen real estate during test runs. Playwright interacts
// with the renderer via CDP, which works without a visible window.
// Why: in E2E headless mode keep the window hidden (Playwright drives via CDP) so tests don't steal focus.
const e2eConfig = getMainE2EConfig()
if (e2eConfig.headless) {
return
@@ -417,17 +345,9 @@ export function createMainWindow(
}
mainWindow.on('ready-to-show', revealInitialWindow)
// Why: persist window bounds so the app restores to the user's last
// position/size instead of maximizing on every launch. Debounce to avoid
// hammering the persistence layer during continuous resize drags.
// Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags.
let boundsTimer: ReturnType<typeof setTimeout> | null = null
// Why: once close has been initiated (user Cmd+Q, auto-updater relaunch,
// app.quit during quitAndInstall), Electron can still emit resize/move/
// unmaximize events while the OS tears the window down — persisting those
// intermediate, often near-minimum bounds would clobber the user's real
// last-used size and cause the next launch (especially post-update
// relaunch) to come up at minWidth × minHeight. Freeze persistence as soon
// as 'close' is observed.
// Why: teardown still emits resize/move/unmaximize at near-min bounds; freeze persistence once closing so they can't clobber the saved size.
let windowClosing = false
const saveBounds = (): void => {
if (boundsTimer) {
@@ -438,25 +358,14 @@ export function createMainWindow(
if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) {
return
}
// Why: windowMaximized and windowBounds must be sampled and persisted
// atomically — writing windowMaximized first and then deciding whether
// to write bounds can leave the store with `windowMaximized: false`
// paired with stale/absent windowBounds if the near-min guard trips,
// which violates the pairing invariant subsequent launches rely on.
// Why: persist windowMaximized and windowBounds atomically; the near-min guard must not leave them a mismatched pair.
const isMaximized = mainWindow.isMaximized()
if (isMaximized) {
store?.updateUI({ windowMaximized: true })
return
}
const bounds = mainWindow.getBounds()
// Why: never persist shrink-to-min bounds. The user cannot want these
// saved — the window hit the enforced minimum, so either the teardown
// race from PR #1269 slipped past the freeze (e.g. dev-mode Ctrl+C
// where will-prevent-unload re-opens the freeze), or a transient
// OS resize fired. Dropping the bounds write here makes the next
// launch fall back to defaultBounds instead of resurrecting a tiny
// window. We still record windowMaximized: false so subsequent
// launches don't incorrectly restore maximized state.
// Why: never persist shrink-to-min bounds (teardown race past the freeze, PR #1269); fall back to defaultBounds next launch.
if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) {
console.warn('[window] Skipping persist of near-minimum windowBounds:', bounds)
store?.updateUI({ windowMaximized: false })
@@ -468,12 +377,7 @@ export function createMainWindow(
mainWindow.on('resize', saveBounds)
mainWindow.on('move', saveBounds)
// Why: the auto-updater install path calls
// `win.removeAllListeners('close')` before quitting, so the per-window
// 'close' handler below never runs for update-triggered relaunches.
// Listen to app-level 'before-quit' as a second latch so resize/move
// events emitted during window teardown don't persist shrink-to-min
// bounds that would be restored on next launch.
// Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown.
const freezeBoundsOnQuit = (): void => {
windowClosing = true
if (boundsTimer) {
@@ -496,9 +400,7 @@ export function createMainWindow(
}
mainWindow.webContents.send('window:maximize-changed', false)
const bounds = mainWindow.getBounds()
// Why: mirror the saveBounds guard — unmaximize during teardown can land
// at MIN_WIDTH × MIN_HEIGHT and we must not persist those as the user's
// remembered size.
// Why: mirror the saveBounds guard — unmaximize during teardown can land at min size; don't persist that as remembered size.
if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) {
console.warn('[window] Skipping unmaximize-time persist of near-min bounds:', bounds)
store?.updateUI({ windowMaximized: false })
@@ -528,23 +430,14 @@ export function createMainWindow(
const normalizedSrc = normalizeBrowserNavigationUrl(src)
const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : ''
// Why: arbitrary sites must stay inside an unprivileged guest surface. We
// fail closed here so a renderer bug cannot smuggle preload, Node, or a
// non-browser partition into the guest and widen the app privilege boundary.
// The one allowed data URL is Orca's inert blank-tab bootstrap page; deny
// every other data URL so the renderer cannot inject arbitrary inline HTML.
// Why: session profiles use per-profile partitions (e.g.
// persist:orca-browser-session-<uuid>). The registry is the sole authority
// for which partitions are valid — renderer-provided strings that are not
// in the allowlist are rejected.
// Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest.
if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) {
event.preventDefault()
return
}
delete webPreferences.preload
// Why: older Electron builds expose preloadURL alongside preload; delete
// both so the guest surface cannot inherit the main preload bridge.
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
delete (webPreferences as Record<string, unknown>).preloadURL
webPreferences.nodeIntegration = false
webPreferences.nodeIntegrationInSubFrames = false
@@ -554,26 +447,18 @@ export function createMainWindow(
webPreferences.allowRunningInsecureContent = false
webPreferences.contextIsolation = true
webPreferences.sandbox = true
// Why: keep renderer-created webviews aligned with the browser guest policy
// even if the host markup omits or misspells a preference.
// Why: force the browser guest policy even if host markup omits or misspells a preference.
Object.assign(webPreferences, ORCA_BROWSER_GUEST_WEB_PREFERENCES)
// Why: preserve the registry-validated partition instead of forcing the
// legacy constant. This lets imported/isolated session profiles use their
// own cookie/storage partition while keeping all other hardening intact.
// Why: keep the registry-validated partition so isolated session profiles use their own storage while other hardening stays intact.
webPreferences.partition = partition
})
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
// Why: popup and navigation policy must attach as soon as Chromium creates
// the guest webContents. Waiting until renderer-driven registration leaves
// a race where target=_blank or early redirects can bypass Orca's intended
// fallback behavior.
// Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it.
browserManager.attachGuestPolicies(guest)
})
// Block ALL in-window navigations to prevent remote pages from inheriting
// the privileged preload bridge (PTY, filesystem, etc.).
// In dev mode, allow navigations to the local dev server (e.g. HMR reloads).
// Why: block in-window navigations so remote pages can't inherit the privileged preload bridge (dev server allowed for HMR).
mainWindow.webContents.on('will-navigate', (event, url) => {
const externalUrl = normalizeExternalBrowserUrl(url)
@@ -596,23 +481,14 @@ export function createMainWindow(
event.preventDefault()
})
// Why: mirrors the renderer's markdown-editor focus state so the main-process
// before-input-event handler can skip Cmd/Ctrl+B interception while TipTap
// owns focus. See docs/markdown-cmd-b-bold-design.md. We only carve out
// Cmd+B so browser guests and other editable surfaces keep the existing
// global shortcut behavior.
// Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md).
let markdownEditorFocused = false
let terminalInputFocused = false
let floatingTerminalInputFocused = false
let shortcutRecorderFocused = false
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
// Why: coerce to strict boolean and verify the sender. A renderer bug or
// compromised IPC payload must not set the flag to a truthy non-bool (e.g.
// an object) and silently disable the sidebar toggle — default-deny on any
// non-bool. Additionally, only this main window's top-level webContents may
// mutate the flag, so a guest/webview or unrelated sender can't disable the
// Cmd+B sidebar carve-out.
// Why: strict-bool + sender check so a guest/webview or malformed IPC payload can't disable the Cmd+B sidebar carve-out.
const onMarkdownEditorFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
@@ -621,8 +497,7 @@ export function createMainWindow(
}
ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused)
const terminalInputFocusChannel = 'ui:setTerminalInputFocused'
// Why: before-input-event resolves shortcuts before renderer keydown. Mirror
// regular xterm focus so Terminal-first can let shells/TUIs own app chords.
// Why: before-input-event resolves shortcuts before renderer keydown; mirror xterm focus so Terminal-first lets shells own app chords.
const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
@@ -631,8 +506,7 @@ export function createMainWindow(
}
ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused)
const floatingTerminalInputFocusChannel = 'ui:setFloatingTerminalInputFocused'
// Why: main before-input-event runs before renderer keydown handlers. Mirror
// floating xterm focus so Ctrl+B/L and related shell chords can reach SSH/tmux.
// Why: before-input-event runs before renderer keydown; mirror floating xterm focus so Ctrl+B/L reach SSH/tmux.
const onFloatingTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
@@ -641,8 +515,7 @@ export function createMainWindow(
}
ipcMain.on(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused'
// Why: the Settings recorder must receive existing app shortcuts so users can
// rebind them; before-input-event would otherwise consume the key first.
// Why: the Settings recorder must receive app shortcuts to rebind them; before-input-event would otherwise consume the key first.
const onShortcutRecorderFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
@@ -656,16 +529,12 @@ export function createMainWindow(
if (template.length === 0) {
return
}
// Why: right-click can produce a Chromium context-menu event before our
// renderer focus mirror updates, so trust Electron's editable/spellcheck
// params here instead of gating on markdownEditorFocused.
// Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused.
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
}
mainWindow.webContents.on('context-menu', onMainContextMenu)
// Why: renderer can't mirror focus state across a crash/reload/close.
// Default-deny the carve-outs so focus context from a dead renderer cannot
// disable app shortcuts in a later lifecycle state.
// Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle.
const resetMarkdownEditorFocus = (): void => {
markdownEditorFocused = false
}
@@ -680,10 +549,7 @@ export function createMainWindow(
}
let rendererProcessGone = false
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null
// Why: stop a deterministic per-load renderer fault (bad GPU driver, corrupt
// chunk, AV interference) from auto-reloading every ~0.25-1.3s forever
// (Windows crash-loop clusters). The breaker opens after too many recoveries
// inside a rolling window and hands off to the host's recovery surface.
// Why: stop a deterministic per-load renderer fault from auto-reloading forever; breaker opens after too many recoveries in a rolling window.
const rendererRecoveryCircuitBreaker = new RendererRecoveryCircuitBreaker({
windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES
@@ -718,8 +584,7 @@ export function createMainWindow(
}
const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now())
if (!recovery.allowed) {
// Why: too many reloads in the window means reloading again will just
// crash again. Stop the loop and let the host surface a recovery prompt.
// Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt.
opts?.onRendererRecoveryExhausted?.({
details,
webContentsId: rendererWebContentsId,
@@ -727,11 +592,8 @@ export function createMainWindow(
})
return
}
// Why: a transient Network Service / renderer loss can leave Chromium
// showing a blank shell. Reload the app document once so the user gets
// back to a usable window instead of needing a full relaunch.
// Why: mark this one in-place reload so the did-finish-load orphan sweep
// spares live local PTYs until session restore re-attaches them (#5787).
// Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover.
// Why: mark this in-place reload so the did-finish-load orphan sweep spares live PTYs until session restore (#5787).
opts?.onBeforeRecoveryReload?.(mainWindow.webContents.id)
loadMainWindow(mainWindow)
}, 250)
@@ -742,11 +604,9 @@ export function createMainWindow(
resetTerminalInputFocus()
resetFloatingTerminalInputFocus()
resetShortcutRecorderFocus()
// Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL
// after a confirmed close; that is window lifecycle noise, not a crash.
// Why: macOS reports BrowserWindow teardown as renderer killed/SIGKILL after close — window noise, not a crash.
if (!windowClosing) {
// Why: the recorder owns crash classification and durable suppression
// diagnostics; filtering here made expected-teardown evidence unreachable.
// Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable.
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
}
if (!windowClosing) {
@@ -775,12 +635,10 @@ export function createMainWindow(
const doubleTapDetector = new ModifierDoubleTapDetector()
// Why: one place maps a resolved window-shortcut action to its IPC/side effect,
// reused by the normal keydown path and the double-tap path so they cannot drift.
// Why: one mapping of action IPC/side effect, shared by the keydown and double-tap paths so they can't drift.
const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => {
switch (action.type) {
// The renderer's DictationController re-checks enabled/sttModel and ignores
// hold mode, so this path needs no voice guards.
// The renderer's DictationController re-checks enabled/sttModel and ignores hold mode, so this path needs no voice guards.
case 'dictationKeyDown':
mainWindow.webContents.send('ui:dictationKeyDown')
return
@@ -861,8 +719,7 @@ export function createMainWindow(
? getWindowShortcutActionId(action)
: null
// Why: hold-mode dictation needs renderer keyup events, so the main process
// may only consume shortcuts that toggle dictation from a single keydown.
// Why: hold-mode dictation needs renderer keyup events, so main only consumes single-keydown dictation toggles.
if (action.type === 'dictationKeyDown') {
const voiceSettings = store?.getSettings().voice
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
@@ -918,8 +775,7 @@ export function createMainWindow(
}
if (isMacAppPasteInput(input)) {
// Why: native chat/terminal panes can own focus without being native
// editable controls, so route Cmd+V through Orca's paste ownership first.
// Why: chat/terminal panes hold focus without native editable controls, so route Cmd+V through Orca's paste ownership.
event.preventDefault()
mainWindow.webContents.send('ui:appMenuPaste')
return
@@ -937,8 +793,7 @@ export function createMainWindow(
terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy
}
// Why: detect double-tap-modifier gestures on the raw key stream. A bare
// modifier emits no terminal bytes, so this never steals readline input.
// Why: bare modifiers emit no terminal bytes, so double-tap detection on the raw key stream never steals readline input.
if (input.type === 'keyDown' || input.type === 'keyUp') {
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
@@ -967,13 +822,10 @@ export function createMainWindow(
focusedShortcutContext: terminalShortcutContext
})
) {
// Only preventDefault the emitting keydown — never the first tap's
// down/up. This suppresses the renderer DOM keydown so the renderer
// detector cannot also fire for the same gesture.
// preventDefault only the emitting keydown so the renderer detector can't also fire for the same gesture.
return
}
// No allowlisted action: let the keydown reach the renderer, whose
// detector completes and dispatches inline.
// No allowlisted action: let the keydown reach the renderer, whose detector completes and dispatches inline.
}
}
@@ -981,15 +833,11 @@ export function createMainWindow(
input.type === 'keyDown' &&
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
) {
// Why: the held switcher commits on modifier keyup. If main prevents the
// keydown, Electron can suppress the renderer keyup and strand the overlay.
// Why: the held switcher commits on modifier keyup; preventing the keydown here can suppress the keyup and strand the overlay.
return
}
// Why: TipTap owns bare Cmd/Ctrl+B for bold while the markdown editor is
// focused — skip interception so its keymap runs. Scoped to the bare chord
// (no Shift/Alt): any extra modifier signals different intent and must
// still resolve through the policy allowlist.
// Why: TipTap owns bare Cmd/Ctrl+B for bold in the markdown editor; skip interception for the bare chord only.
// See docs/markdown-cmd-b-bold-design.md.
const modForBold = process.platform === 'darwin' ? input.meta : input.control
if (
@@ -1002,9 +850,7 @@ export function createMainWindow(
return
}
// Why: keep the main-process interception surface as an explicit allowlist.
// Anything outside this helper must continue to the renderer/PTTY so
// readline control chords are not silently stolen above the terminal.
// Why: keep interception an explicit allowlist so readline control chords reach the PTY instead of being silently stolen.
const action = resolveWindowShortcutAction(
input,
process.platform,
@@ -1025,14 +871,11 @@ export function createMainWindow(
})
})
// Why: a mid-gesture focus loss must not leave the detector armed so the next
// unrelated modifier press completes a phantom double-tap.
// Why: mid-gesture focus loss must not leave the detector armed, or the next modifier press completes a phantom double-tap.
mainWindow.on('blur', () => doubleTapDetector.reset())
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
// Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before
// before-input-event fires, but still emit Electron's zoom command. Keep
// that fallback only while the matching zoom action is still bound.
// Why: some layouts fire Electron's zoom command without before-input-event; honor it only while the zoom action is still bound.
if (zoomDirection !== 'in' && zoomDirection !== 'out') {
return
}
@@ -1055,17 +898,11 @@ export function createMainWindow(
mainWindow.webContents.send('terminal:zoom', zoomDirection)
})
// Intercept window close so the renderer can show a confirmation dialog
// when terminals with running processes would be killed. The renderer
// replies with 'window:confirm-close' to proceed, or does nothing to cancel.
// Intercept close so the renderer can confirm killing running-process terminals (replies window:confirm-close to proceed).
let windowCloseConfirmed = false
const confirmCloseChannel = 'window:confirm-close'
// Why: Windows minimize-to-tray. Hides the window instead of closing when the
// setting is on, this isn't a real quit (Ctrl+Q / tray "Quit" set
// getIsQuitting), and the renderer is alive. Returns true when it handled the
// close by hiding, so callers skip their normal close path. Shared by BOTH the
// renderer-drawn X (window:request-close) and the native close event (Alt+F4).
// Windows minimize-to-tray: hide instead of close when enabled; returns true when it hid so callers skip their close path.
const hideToTrayIfEnabled = (): boolean => {
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
if (
@@ -1078,8 +915,7 @@ export function createMainWindow(
return false
}
mainWindow.hide()
// Why: tell the user once that closing only hid the window; the persisted
// flag stops the notice from repeating on every later minimize.
// Why: notify once that closing only hid the window; the persisted flag stops it repeating on every later minimize.
if (store.getUI().trayMinimizeNoticeShown !== true) {
try {
new Notification({
@@ -1098,35 +934,25 @@ export function createMainWindow(
}
mainWindow.on('close', (e) => {
// Why: Alt+F4 and programmatic closes reach the native event; apply the same
// minimize-to-tray guard the renderer-drawn X uses via onRequestClose.
// Why: Alt+F4/programmatic closes hit the native event; apply the same minimize-to-tray guard the renderer-drawn X uses.
if (!windowCloseConfirmed && hideToTrayIfEnabled()) {
e.preventDefault()
return
}
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
// Why: a hung-but-ALIVE renderer (neither gone nor crashed) must still hit
// the renderer's save/running-process confirmation; only a genuinely gone or
// crashed renderer — which cannot answer window:close-requested — may bypass
// it. Routing this through the pure decision locks that invariant (#5787).
// Why: only a gone/crashed renderer (can't answer) may bypass close confirmation; a hung-but-alive one still must (#5787).
const closeAction = resolveWindowCloseAction({
windowCloseConfirmed,
rendererProcessGone,
isRendererCrashed
})
if (closeAction !== 'request-confirmation') {
// allow-confirmed: the renderer already replied and re-entered close().
// bypass-gone: after a native renderer crash the renderer cannot answer
// window:close-requested, so let Cmd+Q / OS close complete instead of
// trapping the user in a blank, unquittable window.
// allow-confirmed: renderer already replied and re-entered close().
// bypass-gone: a gone renderer can't answer window:close-requested, so let OS close complete rather than trap a blank window.
if (closeAction === 'allow-confirmed') {
windowCloseConfirmed = false
}
// Why: past this point Electron/OS may emit resize/move/unmaximize as
// the window is destroyed. Freeze bounds persistence so those
// teardown events can't clobber the user's saved window size — which
// would otherwise make the post-update relaunch come up at minWidth ×
// minHeight (issue surfaced in v1.3.26-rc2).
// Why: window teardown emits resize/move/unmaximize; freeze bounds persistence so they can't clobber saved size (v1.3.26-rc2).
windowClosing = true
if (boundsTimer) {
clearTimeout(boundsTimer)
@@ -1135,17 +961,13 @@ export function createMainWindow(
return
}
e.preventDefault()
// Why: the renderer owns the close decision (dirty-file save dialogs,
// running-process confirmation). The subscription lives at the always-
// mounted App root, so even pre-workspace states reply — see #5144.
// Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144).
mainWindow.webContents.send('window:close-requested', {
isQuitting: opts?.getIsQuitting?.() ?? false
})
})
mainWindow.webContents.on('will-prevent-unload', () => {
// Why: a prevented beforeunload cancels the in-flight quit. Release the
// bounds-persistence freeze so a user who keeps using the window after
// aborting Cmd+Q still gets their size saved.
// Why: a prevented beforeunload cancels the quit; release the bounds-persistence freeze so later resizing still saves.
windowClosing = false
opts?.onQuitAborted?.()
mainWindow.webContents.send('window:unload-prevented')
@@ -1163,8 +985,7 @@ export function createMainWindow(
}
ipcMain.on(trafficLightChannel, onSyncTrafficLights)
// Why: renderer-drawn window controls on Windows/Linux desktop send these to
// replicate the native title bar buttons hidden by custom chrome.
// Why: renderer-drawn window controls on Windows/Linux replicate the native title-bar buttons hidden by custom chrome.
const minimizeChannel = 'window:minimize'
const onMinimize = (): void => {
if (!mainWindow.isDestroyed()) {
@@ -1182,39 +1003,24 @@ export function createMainWindow(
mainWindow.maximize()
}
}
// Why: send window:close-requested directly rather than calling
// mainWindow.close() and letting the 'close' event re-send it. Calling
// mainWindow.close() from within an IPC message handler on Windows can cause
// the 'close' event to misfire (e.preventDefault() doesn't suppress the OS
// close in all Windows configurations). Going straight to the renderer's
// close guard (Terminal.tsx onWindowCloseRequested) keeps the flow identical
// to what happens when confirmWindowClose() ultimately calls mainWindow.close()
// with windowCloseConfirmed = true.
// Why: mainWindow.close() from an IPC handler on Windows can make 'close' misfire, so send window:close-requested directly.
const requestCloseChannel = 'window:request-close'
const onRequestClose = (): void => {
if (mainWindow.isDestroyed()) {
return
}
// Why: the renderer-drawn X on Windows routes here (not the native close
// event), so the minimize-to-tray guard must run on this path too — hide
// instead of asking the renderer to close.
// Why: renderer-drawn X routes here (not the native close event), so the minimize-to-tray guard must also run here.
if (hideToTrayIfEnabled()) {
return
}
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
}
// Why: the ··· button in the renderer-drawn title bar on Windows/Linux
// desktop pops up the application menu at the cursor position, replicating
// the Alt-key reveal that autoHideMenuBar normally provides.
// Why: renderer-drawn title-bar ··· menu button replicates the Alt-key reveal autoHideMenuBar provides (Windows/Linux).
const popupMenuChannel = 'menu:popup'
const onPopupMenu = (): void => {
Menu.getApplicationMenu()?.popup({ window: mainWindow })
}
// Why: the renderer's WindowControls mounts after ready-to-show, which is
// also when savedMaximized is restored — so window:maximize-changed has
// already fired (or not fired, if maximize() was called pre-mount) before
// the listener attaches. Expose a synchronous getter so the button can
// initialize its icon to match the current state on mount.
// Why: WindowControls mounts after window:maximize-changed already fired, so expose a synchronous getter to init its icon.
const isMaximizedChannel = 'window:isMaximized'
const onIsMaximized = (): boolean => {
return !mainWindow.isDestroyed() && mainWindow.isMaximized()
@@ -1228,9 +1034,7 @@ export function createMainWindow(
ipcMain.on(confirmCloseChannel, onConfirmClose)
mainWindow.on('closed', () => {
clearInitialRevealFallbackTimer()
// Why: default-deny the Cmd+B carve-out after the window is gone so a
// stale-true flag can't leak past subsequent state transitions. Paired
// with the webContents lifecycle resets above.
// Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state.
markdownEditorFocused = false
terminalInputFocused = false
floatingTerminalInputFocused = false
@@ -1248,14 +1052,10 @@ export function createMainWindow(
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
// Why: powerMonitor is app-global; without this the closed window's
// resume relay would leak and fire against a destroyed webContents.
// Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents.
powerMonitor.removeListener('resume', onSystemResume)
clearTrustedUIRendererWebContentsId(rendererWebContentsId)
// Why: on updater-triggered shutdown, BrowserWindow can emit `closed`
// after its webContents has already been destroyed. The destroyed
// webContents owns its listeners, so do not touch `mainWindow.webContents`
// here or the quit path can crash before Squirrel.Mac relaunches Orca.
// Why: on updater shutdown 'closed' can fire after webContents is destroyed, so don't touch mainWindow.webContents here.
app.removeListener('before-quit', freezeBoundsOnQuit)
})