mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+15
-51
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+263
-945
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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 allowlisted — merge-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 ~1–3s 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 ~1–3s 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,
|
||||
|
||||
@@ -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 1–3s 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, 1–3s) 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 1–3s GitHub round-trip per click; this test fails
|
||||
// loudly instead.
|
||||
// Why: guard against a refactor reintroducing the PR probe on the happy path (1–3s 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
@@ -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 1–2.
|
||||
// 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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, '-')
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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 1–4 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 1–4 to a string. Idempotent — running this twice yields the
|
||||
* same output as once, which is what makes triple-application safe.
|
||||
*/
|
||||
/** Apply rules 1–4 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 1–4;
|
||||
* 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 1–4; 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 1–4 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 1–4 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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
+155
-455
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 state—not 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}`)
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user