Merge origin/main into manual-worktree-delete

# Conflicts:
#	src/main/runtime/orca-runtime.ts
This commit is contained in:
Jinwoo Hong
2026-07-10 02:01:58 -04:00
207 changed files with 13144 additions and 1784 deletions
+129 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-08",
"updatedAt": "2026-07-09",
"policy": {
"maturityLevels": [
"experimental",
@@ -105,6 +105,134 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.kill-all-surface-cleanup",
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-main-contract",
"surfaces": [
"terminal lifecycle",
"terminal tab cleanup",
"PTY shutdown",
"Manage Sessions",
"Resource Manager"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime",
"mobile-relay"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon",
"ssh"
],
"coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8001"
],
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts"
],
"testFiles": [
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"src/renderer/src/components/shared/useDaemonActions.test.tsx",
"src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
"src/main/ipc/pty-management.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"assertions": [
"snapshot deduplicates legacy, unified-only, split, multi-worktree, and floating terminal surfaces while excluding editor tabs",
"cleanup-time moves, active-worktree switches, rebinding, missing targets, and later-created tabs preserve the confirmation boundary and active-last order",
"current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion",
"the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep",
"management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics",
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms"
]
},
{
"file": "src/renderer/src/components/shared/useDaemonActions.test.tsx",
"assertions": [
"the hook snapshots before onKillAllStart and before coordinator work",
"unmounting the invoking component does not revoke cleanup while React callbacks remain mount-gated",
"error and settled callbacks run only after coordinator settlement",
"closed terminal tabs report success instead of the no-sessions informational state when daemon management reports zero"
]
},
{
"file": "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
"assertions": [
"force closes pinned terminals without a second confirmation",
"closing the last active terminal preserves and activates editor or browser content, otherwise deactivates without auto-spawn"
]
},
{
"file": "src/main/ipc/pty-management.test.ts",
"assertions": [
"killAll fires one shutdown for each initial daemon session and polls those initial IDs until empty",
"freshly respawned session IDs are excluded from remainingCount",
"per-session shutdown rejection does not stop the daemon batch and refused initial sessions remain reported"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-09",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts",
"result": "passed",
"durationSeconds": 1.31,
"summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "focused renderer/main unit and performance-count tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The focused 27-test slice passed locally once; it needs CI and soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms."
},
"promotionCriteria": [
"Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach Windows Electron evidence for both entry points, empty and established terminals, later-tab survival, xterm removal, and initial PTY absence after settlement.",
"Exercise the SSH fixture and WSL when available, or keep their live process-absence gaps explicit.",
"Propagate and align runtime-host tab-close completion before claiming verified remote-runtime shutdown."
],
"knownGaps": [
"Windows Electron screenshots and live process/xterm absence evidence were not produced by this local macOS run.",
"Live SSH and WSL process absence, Linux local/daemon behavior, and mobile/relay shutdown remain unproved.",
"Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.",
"Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process."
],
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug."
},
{
"id": "terminal-session.startup-cwd-missing-dir-recovery",
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
+1 -1
View File
@@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`:
- **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover.
- **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces.
- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still.
- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar.
Apply one of these to overflow containers; don't write a fourth style.
@@ -0,0 +1,203 @@
# Kill all sessions also closes terminal tabs
GitHub: https://github.com/stablyai/orca/issues/8001
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
## Problem
Settings → Manage Sessions and Resource Manager expose **Kill all sessions**. The action currently calls only `window.api.pty.management.killAll()`; it does not remove renderer terminal tabs or dispose xterm instances.
The main handler is narrower than the UI wording:
- `pty:management:killAll` snapshots sessions from the current and legacy **daemon** adapters, shuts them down in parallel, then polls only those initial IDs.
- It does not inventory SSH or runtime-hosted terminals. A degraded local provider is also outside the management adapter set.
- The nominal poll sleep budget is 6.5 seconds (65 × 100 ms), but adapter round-trip time is additional. Daemon requests time out after 30 seconds and connection setup has separate 5-second steps, so total latency can be much longer.
- Failed adapter listings are dropped by `collectSessions`, so counts are not authoritative when an adapter is unreachable. This applies during polling too: an adapter that listed an initial session and then becomes unreachable can make that session disappear from `remainingCount` and be counted as killed without a confirming listing.
Renderer exit handling then preserves two important startup-failure states:
- a sole, freshly spawned pane that exits before any user input; and
- a freshly split pane that exits before input or output.
Those guards are correct for direnv/shell setup failures, but they cannot distinguish an explicit bulk kill. A sole fresh-spawned pane remains if the user never typed, even if it produced output or lived for a while; typed/reattached panes normally close through the existing exit path.
## Goal and invariant
After confirmation, every terminal tab that existed in the invoking desktop renderer at that moment is removed, including dead/no-PTY tabs, and its renderer resources are disposed. Terminal tabs created later are not targeted. Initial daemon sessions still receive the existing management shutdown request, while current non-runtime PTY bindings owned by the targeted tabs receive exact per-ID shutdown requests.
The target is the confirmed terminal **surface ID**, not a liveness inference. If the same targeted tab rebinds before cleanup completes, it is still closed; a newly created tab ID survives.
## Non-goals
- Changing **Restart daemon**, which intentionally leaves panes available to reopen.
- Deleting or sleeping worktrees, closing browser/editor tabs, or killing the daemon process.
- Changing the sole-pane/fresh-split startup-failure guards.
- Promising an immediate OS working-set drop; GC and allocator behavior are nondeterministic. The deterministic contract is that tabs/xterms are released and observable daemon/exact-binding shutdown-request failures are surfaced. Runtime-host result propagation remains an accepted gap below.
- Turning this issue into a new cross-provider global kill API. Exact `pty.kill(id)` calls for current bindings of the confirmed tabs are in scope; inventorying and killing unrelated provider sessions is not.
## Design
### 1. Snapshot terminal surfaces once
At confirmation, before `onKillAllStart` and before the first `await`, collect and deduplicate terminal entity IDs from both `tabsByWorktree` and terminal entries in `unifiedTabsByWorktree`. Include the floating-terminal workspace.
Keep this immutable target set through the async daemon call:
- a target already closed elsewhere becomes a no-op;
- a target moved between groups/worktrees is resolved from current state and still closes;
- a terminal created after the snapshot is never closed.
Do not snapshot all terminal IDs again after the daemon call, and do not retain the confirmation-time worktree as cleanup authority. The target ID is immutable; ownership and active-last ordering are resolved again from current state immediately before cleanup.
### 2. Keep daemon shutdown semantics, then close the snapshot
Call the existing `window.api.pty.management.killAll()` once. After it settles, resolve the still-present targets and deduplicate their current `ptyIdsByTabId` entries. These are the renderer's current binding ownership records, not independent liveness proof; do not pull IDs from `tab.ptyId`, deferred SSH restore state, or `terminalLayoutsByTabId.ptyIdsByLeafId`, which can be restore hints rather than current ownership.
Force-close every snapshotted surface through `closeTerminalTab(id, { force: true })`, then await one existing `window.api.pty.kill(id)` call for each captured non-`remote:` PTY ID. Run both phases for management success, partial success, and IPC rejection because the confirmation explicitly covers closing the terminal surfaces. Use per-target/per-PTY settlement so one unexpected store or provider failure does not stop the remaining cleanup. Already-gone PTYs are successful no-ops in the main handler; other exact-kill rejections are reported separately.
Close targets outside the cleanup-time `activeWorktreeId` first and targets currently owned by it last. A tab moved into the active worktree or a workspace selected while the daemon call was pending must therefore move to the last partition. Let `closeTerminalTab` choose the existing post-state for the last local terminal: editor, then browser, then `setActiveWorktree(null)`. Do not pre-clear `activeWorktreeId`; the sleep flow needs that ordering because it leaves tab records mounted while clearing PTYs, whereas this flow removes the tabs. Add active-switch and active-last regression tests and only adopt sleep-intent/deactivate-first if those tests prove a respawn.
This is not one backend call end-to-end. The coordinator issues one daemon-management IPC, one renderer close per unique target, and at most one exact kill IPC per unique current non-runtime PTY binding. The explicit exact kills make local/SSH shutdown requests settle before caller refresh instead of relying on React unmount timing. A mounted transport may race that request and issue an idempotent duplicate during unmount; count that separately in live performance evidence. A still-bound daemon session can receive the exact retry after the management poll, so daemon toast counts remain the management handler's earlier reported snapshot, not final process truth. Runtime-hosted tabs additionally issue one `session.tabs.close` flow per tab.
The coordinator must live outside the hook (for example, `kill-all-terminal-surfaces.ts`) and must not gate cleanup on `mountedRef`. Navigating away or unmounting the invoking popover/settings component while IPC is pending does not revoke an already confirmed destructive action; only React callbacks and hook state updates remain mount-gated.
### 3. Do not add a post-kill orphan sweep
Do not call `pty.listSessions()` and kill everything returned after the management call. Exact kills captured from `ptyIdsByTabId` for the immutable target surfaces are allowed and required; they do not discover or cross into unrelated sessions.
The management handler already targets daemon orphans in its initial snapshot. A later broad sweep would cross the confirmation boundary by killing local/SSH sessions or fresh sessions created while the existing poll was running. It would also add provider inventory fan-out to a recovery action and conflict with the main handler's initial-ID accounting.
The existing explicit **Kill orphan terminals** action remains separate.
### 4. Make completion and failure copy honest
Have the renderer cleanup return a bounded summary: target count, targets absent at completion, failed close attempts, exact PTY kill requests accepted/rejected, and daemon result/error. Verify absence in both terminal and unified state after each close attempt. Toasts must report renderer cleanup separately from daemon counts; do not show **No sessions running** when terminal tabs were closed.
Because the management API suppresses adapter-list failures, copy must describe daemon counts as reported, not claim that every daemon process was verified dead. Returning adapter errors is a follow-up unless this PR chooses to widen the API. Exact `pty.kill` rejection counts may be stated as failed shutdown requests, not as proof that the processes remain alive.
Examples of states, not required wording:
- all daemon targets were reported exited, exact shutdown requests were accepted, and tabs closed: success;
- `remainingCount > 0`: warning that management reported daemon processes not exited before exact binding cleanup;
- management IPC rejected: error that tabs closed, with daemon shutdown unverified and any exact PTY kill failures called out;
- no daemon sessions and no terminal tabs: existing informational state.
Run `onKillAllSettled` only after surface removal and exact PTY kill settlement. `closeTerminalTab` returns before React unmount effects run, so surface removal alone is not a sufficient boundary for Resource Manager's `pty.listSessions()` refresh. Keep one confirmation dialog and update its description to say that terminal tabs across workspaces close and unsaved terminal work is lost.
### 5. Provider limits
`closeTerminalTab` synchronously prunes host-backed local mirrors but currently returns `void` and discards the promise from `closeWebRuntimeSessionTab`. The underlying helper is already awaitable: its close request has a 15-second timeout and, after success, it awaits an eager list refresh with another 15-second timeout. Its boolean can therefore settle after roughly 30 seconds, while close-intent suppression expires after 10 seconds. This change must not claim verified runtime-host completion unless the bulk helper propagates that existing result and aligns the intent lifetime.
For this Windows/local bug, runtime-host cleanup is best-effort and an explicit accepted gap. A follow-up may propagate the existing async close result through terminal-tab actions and align the RPC timeout/intent lifetime. SSH must be exercised if the fixture is available; otherwise record live SSH process absence as an accepted gap. No provider-specific local filesystem or process assumptions are added.
## Data flow
```text
confirm
→ snapshot unique terminal surface IDs
→ pty.management.killAll() daemon current + legacy; existing polling
→ resolve current target ownership + bound PTY IDs
→ close snapshotted tabs, active last renderer/xterm cleanup; runtime-host close starts
→ await exact non-runtime pty.kill IDs local/SSH acknowledgement; no provider sweep
→ toast daemon result + surface/exact-kill summary
→ caller refresh
```
## Concurrency and consistency
| Case | Required behavior |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| No daemon sessions; dead/empty tabs exist | Close target tabs and report tab cleanup, not “No sessions.” |
| Daemon session refuses to exit | Close the confirmed tab; report that management saw it remaining before the exact retry. Refresh may expose it as an orphan. |
| Adapter listing fails | Close target tabs; do not claim all processes were verified dead. Existing management counts may read as zero. |
| Management IPC rejects | Still close confirmed tabs and issue exact current-binding kills; report daemon uncertainty and exact-kill errors. |
| New tab/session during the wait | New tab ID survives. No post-snapshot provider sweep. |
| Target closes or moves during the wait | Missing target no-ops; moved target closes by current ownership. |
| Target tab rebinds to a new PTY | Close it because the confirmed surface, not the old PTY snapshot, is the authority. |
| Pinned or split terminal | `force: true` bypasses a second prompt; closing the tab closes all of its panes. |
| Active last terminal | Existing close routing selects editor/browser or deactivates; no replacement terminal spawns. |
| Two overlapping invocations | Cleanup is idempotent by tab ID. Each main call retains its own initial daemon snapshot/counts. |
| Binding changes after exact-ID capture | Surface close still wins; transport unmount kills the replacement, but that late kill is not in the awaited count. |
| Runtime-host snapshot races close | Existing close intent suppresses stale snapshots temporarily; host completion remains an accepted gap. |
| External/mobile session creation | Sessions created after the main/renderer snapshots are not chased. Existing PTY-exit publication updates mirrors. |
Orca's production UI currently tracks a single desktop `mainWindow`/renderer. The main daemon action is process-global while renderer cleanup is renderer-local. If multiple desktop windows become supported, this action must be broadcast from main with an action ID and per-window acknowledgement; “shared store probably mirrors it” is not sufficient.
## Tests and reliability gate
Add an experimental, `protection: "partial"` `terminal-session.kill-all-surface-cleanup` entry to `config/reliability-gates.jsonc`, owned by `terminal-runtime` at the renderer/main contract layer. The entry must name the focused coordinator and `pty-management` test files/commands and include non-empty assertion refs; otherwise the manifest checker rejects a partial gate.
- **Invariant:** confirmed terminal surfaces close exactly once; later-created surfaces survive; no broad post-snapshot kill occurs.
- **Failure source:** issue #8001 plus the sole-fresh/fresh-split exit guards that intentionally preserve dead startup-failure panes.
- **Deterministic oracle:** the initial tab IDs disappear from both terminal/unified state, non-terminal and later-created tabs remain, active state is valid, exact kills are issued only for deduplicated current target bindings, all exact-kill promises settle before the caller callback, and no `pty.listSessions` sweep occurs. Provider inventory absence is live validation, not the renderer-unit oracle.
- **Diagnostics:** emit one content-free bounded summary with target/absent/failed-close counts, exact-kill accepted/rejected counts, and the daemon result or error. Runtime-host completion remains unknown until terminal-tab actions propagate the existing async close result.
Required deterministic coverage:
- snapshot/dedup across multiple worktrees, unified-only terminals, splits, and floating terminals;
- pinned close with `force`, while browser/editor tabs remain;
- terminal added after the snapshot survives; missing/moved target behavior;
- active-last post-state for editor, browser, and no-other-content cases, including no auto-spawn;
- coordinator ordering for success, partial result, zero sessions, and rejected management IPC;
- exact binding capture excludes restore hints and `remote:` IDs; late-created tab bindings are not captured;
- exactly one management call, zero `pty.listSessions` calls, one close attempt per unique target, and one coordinator-issued exact kill per unique captured non-runtime PTY ID;
- invoking component unmount during the management wait does not cancel store/provider cleanup or invoke mount-gated callbacks;
- existing `src/main/ipc/pty-management.test.ts` cases remain green.
Electron validation on Windows must reproduce an empty shell plus an established terminal, invoke each entry point, and prove:
- target terminal tabs and `.xterm` surfaces are gone;
- initial local/daemon PTY IDs are absent after settle, except daemon IDs reported remaining and exact IDs whose shutdown request failed;
- a terminal created after confirmation survives;
- Resource Manager and Manage Sessions refresh to the same result.
Planned provider/platform accounting:
| Row | Planned status |
| --- | --- |
| Local PTY / daemon / Windows | Covered by deterministic coordinator/main tests plus the required live Electron run. |
| macOS / Linux local and daemon | Shared code is covered deterministically; live process absence remains an accepted gap until run there. |
| SSH | Exact request/settlement is covered by provider-contract mocks; live remote-process absence is covered only when the SSH fixture runs, otherwise accepted-gap. |
| WSL | Uses the local exact-ID path, but real WSL process absence is an accepted gap unless included in the Windows run. |
| Remote runtime | Local mirror removal is covered; host completion is accepted-gap while `closeTerminalTab` discards the async result. |
| Mobile/relay | No newly created session is chased; shared host-tab disappearance and live relay shutdown remain accepted gaps unless exercised with their fixtures. |
Screenshots are UI evidence only; they do not prove PTY or memory cleanup.
## Performance and blast radius
No interval, watcher, or extra provider listing is added. The existing management call can wait for 65 sleeps plus adapter latency. The coordinator adds at most one existing exact-kill IPC per unique current non-runtime PTY binding; record both those calls and any idempotent unmount duplicates as part of the performance evidence.
Repeated `closeTerminalTab` calls are not O(tabs): target resolution and store cascades scan/clone multiple maps per close, so worst-case renderer work is superlinear. Measure the synchronous close phase with a representative high-tab fixture (at least 100 terminal tabs). Record close attempts, Zustand writes, duration, and long tasks. If it creates a >50 ms renderer task, add a focused bulk reducer or yield-safe batching rather than declaring the rare action free.
For resource reclamation, prefer deterministic counts: zero target `TerminalPane`/xterm instances, released listeners/transports, and no initial PTY IDs. A repeated open/kill cycle may supplement this with heap/working-set evidence, but absolute memory deltas are not a stable pass/fail oracle.
## UI quality bar and screenshots
No new layout or visual token is needed. Keep the existing shadcn `Dialog`/destructive `Button`, compact typography, dismissal lock while busy, and accurate consequence-first copy. The post-state must never be a blank/crashed TerminalPane or a wall of dead empty panes.
Required review screenshots:
1. Resource Manager before kill with an empty terminal and an established terminal visible in the workspace.
2. Resource Manager kill-all confirmation with updated close-tabs copy.
3. Matched post-kill workspace showing target terminal tabs gone and refreshed counts.
4. Manage Sessions confirmation or post-state, proving the second entry point uses the same behavior.
## Lightweight Eng Review
- **Scope:** renderer surface cleanup layered on the existing daemon management action; no daemon redesign and no broad orphan/provider sweep.
- **Architecture/data flow:** main owns its initial daemon-session shutdown; renderer owns the confirmed terminal-surface snapshot, exact current-binding shutdown, and tab/xterm disposal; runtime-host close remains behind existing routing.
- **Failure modes:** partial/rejected daemon kill, exact provider-kill failure, no-session cleanup, pinning, splits, cleanup-time active selection, new/moved/disappeared tabs, same-tab rebind, overlapping actions, invoking-component unmount, stale runtime snapshots, adapter-list uncertainty, and future multi-window ownership are explicit above.
- **Tests:** deterministic state/coordinator and main-handler tests plus Windows Electron proof; a new experimental partial reliability gate records provider gaps.
- **Performance/blast radius:** one existing management IPC, bounded exact per-PTY kill IPCs, no new listing/polling, but N close cascades require measurement because current helpers are not linear or atomic.
- **UI quality bar:** existing dialog primitives and truthful copy; screenshots prove both entry points and the terminal-free post-state, not memory reclamation.
- **Residual risks:** daemon adapter listing can stall until client timeouts and failures are treated as empty; terminal-tab actions discard the runtime-host close result and its 10-second intent is shorter than the possible two-request close flow; a binding created after exact-ID capture is killed only by transport unmount; live SSH/WSL/mobile behavior may remain accepted gaps until fixtures prove it.
## Rollout
1. Add the snapshot/coordinator and deterministic tests.
2. Wire it only through `useDaemonActions.runKillAll`; keep the coordinator outside the hook and update toast/dialog copy.
3. Add the experimental reliability gate and performance count/latency evidence.
4. Run focused tests, typecheck, lint, and max-lines ratchet checks.
5. Validate both entry points in Electron on Windows and capture the required screenshots.
6. Force-add this doc when staging because `docs/**` is ignored by default.
-79
View File
@@ -1,79 +0,0 @@
# New Worktree Sidebar Reveal
## Problem
Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation.
Current behavior:
- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options.
- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`).
- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`.
Result: off-screen targets animate by default, including freshly created worktrees.
## Root Cause
`activateAndRevealWorktree` conflates two intents:
1. activate existing worktree navigation (smooth reveal is fine);
2. activate a just-created worktree (must jump immediately).
Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`.
## Scope and Non-goals
- Add an opt-in reveal behavior at activation call sites.
- Apply `'auto'` only where the worktree is newly created/added in that flow.
- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in.
- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth).
- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling.
## Design
1. Extend `activateAndRevealWorktree` options:
- `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`.
2. In `activateAndRevealWorktree`, call:
- `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided;
- otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged.
3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows:
- `useComposerState` full-create path;
- `useComposerState` quick-create path;
- `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree;
- `launch-work-item-direct`;
- folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path).
4. Keep existing navigation activations smooth, including:
- `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created);
- all existing `activateAndRevealWorktree(...)` callers that do not opt in.
5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`.
## Correctness Notes and Edge Cases
- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded.
- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending.
- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution.
- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior.
- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued).
- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations.
- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally.
- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators.
## Tests
Add/adjust focused tests in `worktree-activation` coverage:
- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`;
- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth).
Add call-site regression tests (recommended, small):
- one composer create path passes `'auto'`;
- one non-created navigation path stays default (no behavior option).
## Rollout
1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`.
2. Update created-worktree callers to pass `'auto'`.
3. Add tests above.
4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`.
5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation.
+10 -2
View File
@@ -76,6 +76,7 @@ import {
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -2690,6 +2691,11 @@ export default function SessionScreen() {
timers.push(setTimeout(fn, ms))
}
void (async () => {
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1') {
// Why: mobile needs host-owned tabs hydrated for this route, but should
// not pull other paired clients, especially desktop, into this worktree.
@@ -2698,6 +2704,7 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
notifyClients: false
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
}
if (disposed) {
@@ -2719,12 +2726,13 @@ export default function SessionScreen() {
return
}
void (async () => {
await client
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
@@ -2740,7 +2748,7 @@ export default function SessionScreen() {
clearTimeout(t)
}
}
}, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId])
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
useEffect(() => {
if (!client || connState !== 'connected') {
+1 -1
View File
@@ -1165,7 +1165,7 @@
Changes
</button>
<button class="segment" data-seg="pr" onclick="switchSeg('pr')">Pull Request</button>
<button class="segment" data-seg="history" onclick="switchSeg('history')">History</button>
<button class="segment" data-seg="history" onclick="switchSeg('history')">Commits</button>
</div>
<!-- Persistent branch card -->
+1 -1
View File
@@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8
function openMarkdownUrl(url: string): void {
const trimmed = url.trim()
if (/^(https?:|mailto:)/i.test(trimmed)) {
void Linking.openURL(trimmed)
void Linking.openURL(trimmed).catch(() => {})
}
}
+39 -19
View File
@@ -39,6 +39,8 @@ type Props = {
gitStatus: MobileGitStatusResult | null
headSha: string | null
bottomInset?: number
// Hub chrome already shows open-on-web; hide the in-body icon there.
showOpenOnWeb?: boolean
}
// Mutation hooks run unconditionally here and gate internally until a PR is ready.
@@ -52,17 +54,15 @@ export function MobilePRSidebar({
gitBranch,
gitStatus,
headSha,
bottomInset = 0
bottomInset = 0,
showOpenOnWeb = true
}: Props) {
const branch = prSidebarRenderBranch(state)
// prNumber is 0 until ready; the hook gates on `ready` so it never fires early.
const prNumber = state.kind === 'ready' ? state.data.pr.number : 0
const prRepo =
state.kind === 'ready'
? state.data.pr.prRepo
? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo }
: null
: null
// Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render
// reallocates and thrash-updates the mutation/comment/title hooks.
const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null
const actions = useMobilePrActions({
client,
connState,
@@ -95,6 +95,9 @@ export function MobilePRSidebar({
style={{ flex: 1 }}
contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]}
keyboardShouldPersistTaps="handled"
// Why: root-comment / reply composers sit at the bottom of this scroll
// area; without keyboard insets the focused field stays under the keyboard.
automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
<PrSidebarContent
@@ -111,6 +114,7 @@ export function MobilePRSidebar({
commentActions={commentActions}
titleAction={titleAction}
triage={triage}
showOpenOnWeb={showOpenOnWeb}
/>
</ScrollView>
)
@@ -129,7 +133,8 @@ function PrSidebarContent({
actions,
commentActions,
titleAction,
triage
triage,
showOpenOnWeb
}: {
branch: ReturnType<typeof prSidebarRenderBranch>
state: PrSidebarState
@@ -144,6 +149,7 @@ function PrSidebarContent({
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
showOpenOnWeb: boolean
}) {
if (branch === 'loading') {
return (
@@ -209,6 +215,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
showOpenOnWeb={showOpenOnWeb}
/>
)
}
@@ -223,7 +230,8 @@ function PrSidebarSections({
commentActions,
titleAction,
triage,
refetch
refetch,
showOpenOnWeb
}: {
data: Extract<PrSidebarState, { kind: 'ready' }>['data']
client: RpcClient | null
@@ -233,6 +241,7 @@ function PrSidebarSections({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
refetch: () => void
showOpenOnWeb: boolean
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -262,19 +271,30 @@ function PrSidebarSections({
isBusy: triage.isBusy('resolve-conflicts'),
error: triage.error
}
// One card for identity + actions so the ready PR isn't a stack of thin
// duplicate blocks (badge row, title, branches, then another action band).
return (
<>
<PRSidebarHeader pr={data.pr} details={data.details} titleAction={titleAction} />
{/* Conflicting-files section mirrors desktop order: directly below the header,
before actions/checks. Renders only when the PR has merge conflicts. */}
<View style={styles.section}>
<View style={styles.sectionBody}>
<PRSidebarHeader
pr={data.pr}
details={data.details}
titleAction={titleAction}
showOpenOnWeb={showOpenOnWeb}
bare
/>
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
</View>
</View>
{/* Own titled section when present; null otherwise (no empty chrome). */}
<PRConflictingFilesSection pr={data.pr} triage={conflictsTriage} />
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
<PRReviewersSection
details={data.details}
actions={actions}
@@ -45,5 +45,8 @@ export function MobilePrComposeSheet({
}
export function openMobilePrUrl(url: string): void {
void Linking.openURL(url)
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
// (in promise) Error: Unable to open URL…" over the PR screen.
void Linking.openURL(url).catch(() => {})
}
@@ -62,6 +62,8 @@ export function MobilePrViewPanelBody({
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
// Hub header already hosts open-on-web while this segment is active.
showOpenOnWeb={false}
/>
</View>
)
@@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
import { ConfirmModal } from '../ConfirmModal'
import { PRSection } from './PRSection'
import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability'
import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state'
import { prActionsStyles as styles } from './pr-actions-styles'
@@ -25,12 +24,13 @@ type Confirm =
| { kind: 'merge'; method: GitHubPRMergeMethod }
| { kind: 'state'; state: 'open' | 'closed' }
// Merge, auto-merge toggle, and close/reopen. Destructive actions route through
// ConfirmModal first (R5). The firing row shows a spinner in place of its icon
// and disables; other rows stay interactive (uniform visual).
// Merge primary; Close/Reopen + Unlink share one secondary row. No section title —
// button labels are self-explanatory and a header wasted a full row on mobile.
export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) {
const [confirm, setConfirm] = useState<Confirm | null>(null)
const [unlinking, setUnlinking] = useState(false)
// Local unlink errors — unlink is not routed through the actions engine.
const [unlinkError, setUnlinkError] = useState<string | null>(null)
// Mobile keeps merge one-tap: use the repo default instead of surfacing a
// desktop-style method picker in the narrow PR action stack.
@@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const mergeBusy = actions.isBusy({ kind: 'merge' })
const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' })
const stateBusy = actions.isBusy({ kind: 'state' })
const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy
const showAutoMerge =
avail.canAutoMerge &&
canShowMobilePRAutoMergeControl({
...pr,
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
})
const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink
const actionError = unlinkError ?? actions.error
const unlink = useCallback(async (): Promise<void> => {
if (!client || unlinking) {
return
}
setUnlinking(true)
setUnlinkError(null)
try {
const outcome = await unlinkMobilePr(client, worktreeId)
if (outcome.ok) {
onUnlinked()
} else {
setUnlinkError(outcome.error)
}
} catch (err) {
setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.')
} finally {
setUnlinking(false)
}
@@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
if (!confirm) {
return
}
// Engine errors take over the shared error line after this; drop unlink text.
setUnlinkError(null)
if (confirm.kind === 'merge') {
actions.merge(confirm.method)
} else {
@@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const copy = confirmCopy()
return (
<PRSection title="Actions">
{/* Merge controls only while the PR can still be merged (open/draft). */}
<View style={styles.actionsBlock}>
{avail.canMerge ? (
<Pressable
style={[
@@ -108,7 +117,10 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
styles.actionButtonMerge,
mergeBusy && styles.actionButtonDisabled
]}
onPress={() => setConfirm({ kind: 'merge', method: effectiveMethod })}
onPress={() => {
setUnlinkError(null)
setConfirm({ kind: 'merge', method: effectiveMethod })
}}
disabled={mergeBusy}
accessibilityRole="button"
accessibilityLabel="Merge pull request"
@@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
</Pressable>
) : null}
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{showAutoMerge ? (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
<Pressable
style={[styles.togglePill, autoMerge && styles.togglePillOn]}
onPress={() => actions.setAutoMerge(!autoMerge, effectiveMethod)}
onPress={() => {
setUnlinkError(null)
actions.setAutoMerge(!autoMerge, effectiveMethod)
}}
disabled={autoMergeBusy}
accessibilityRole="switch"
accessibilityState={{ checked: autoMerge }}
@@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
</View>
) : null}
{/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */}
{avail.canClose || avail.canReopen ? (
<Pressable
style={[styles.actionButton, stateBusy && styles.actionButtonDisabled]}
onPress={() => setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[styles.actionButtonText, avail.canClose && styles.actionButtonDestructiveText]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
{showSecondary ? (
<View style={styles.secondaryRow}>
{avail.canClose || avail.canReopen ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
stateBusy && styles.actionButtonDisabled
]}
onPress={() => {
setUnlinkError(null)
setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })
}}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[
styles.actionButtonText,
avail.canClose && styles.actionButtonDestructiveText
]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
) : null}
{avail.canUnlink ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
unlinkBusy && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinkBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
</View>
) : null}
{/* Unlink the PR from this worktree. Disabled while another PR mutation is in
flight so clearing the link can't race a merge/close refetch. */}
{avail.canUnlink ? (
<Pressable
style={[
styles.actionButton,
(unlinking || mergeBusy || autoMergeBusy || stateBusy) && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
{actionError ? <Text style={styles.actionError}>{actionError}</Text> : null}
{actions.error ? <Text style={styles.actionError}>{actions.error}</Text> : null}
{/* A Modal is taken out of the flex flow, so it adds no body gap here. */}
<ConfirmModal
visible={confirm !== null}
title={copy.title}
@@ -199,6 +223,6 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
onConfirm={runConfirmed}
onCancel={() => setConfirm(null)}
/>
</PRSection>
</View>
)
}
@@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
import { colors } from '../../theme/mobile-theme'
import { canAddRootComment } from '../../session/pr-comment-actions'
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions'
import { PRSection } from './PRSection'
import { CommentMarkdown } from './CommentMarkdown'
@@ -50,10 +51,15 @@ const COMMENT_PAGE = 12
// review comments, reactions, and collapsible resolved threads.
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const body = details?.body ?? ''
const comments = useMemo(() => details?.comments ?? [], [details])
const isPr = details?.item.type === 'pr'
const comments = useMemo(
() => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []),
[details]
)
const isPr = details != null && !detailsFailed && details.item.type === 'pr'
// Per-card action bundle (stable callbacks from the hook) — built once so the
// memo'd cards don't re-render on unrelated timeline changes.
@@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
<PRSection title="Description">
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.noDescription}>
Could not load description. Tap refresh to try again.
</Text>
) : body.trim() ? (
<CommentMarkdown content={body} variant="document" />
) : (
@@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
>
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.empty}>Could not load comments. Tap refresh to try again.</Text>
) : (
<View style={styles.list}>
{comments.length === 0 ? (
@@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import { getPRReviewerRows } from './pr-checks-presentation'
import { ReviewerPickerDrawer } from './ReviewerPickerDrawer'
import { PRSection } from './PRSection'
@@ -20,8 +21,17 @@ type Props = {
// Requested reviewers + their latest review status, with a picker to request /
// remove (optimistic add/remove via the actions hook).
export function PRReviewersSection({ details, actions, client, worktreeId }: Props) {
// details === null means phase 2 (work-item payload) is still in flight — same
// signal Comments uses. Do not treat that as "no reviewers" or the section goes
// blank while checks (phase 1) already paint. A synthetic placeholder means
// phase 2 failed (no body/comments/reviews landed).
const loadingDetails = details === null
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const authoritativeRows = useMemo(
() => (details?.item ? getPRReviewerRows(details.item) : []),
() =>
details?.item && !isPrSidebarDetailsPlaceholder(details)
? getPRReviewerRows(details.item)
: [],
[details]
)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro
return author ? [author, ...logins] : logins
}, [authoritativeRows, details])
return (
<PRSection
title="Reviewers"
trailing={
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
}
const addButton = (
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
{rows.length === 0 ? (
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
)
return (
<PRSection title="Reviewers" trailing={addButton}>
{loadingDetails ? (
<View style={styles.reviewersStatus}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.emptyText}>Loading reviewers…</Text>
</View>
) : detailsFailed ? (
<Text style={styles.emptyText}>Could not load reviewers. Tap refresh to try again.</Text>
) : rows.length === 0 ? (
<Text style={styles.emptyText}>No reviewers requested</Text>
) : (
rows.map((row) => {
+13 -7
View File
@@ -3,22 +3,28 @@ import { Text, View } from 'react-native'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
type Props = {
title: string
// Optional: omit for self-explanatory sections (e.g. action buttons) so the
// header row doesn't waste vertical space on mobile.
title?: string
// Optional trailing control(s) in the header row (e.g. add-reviewer, checks
// summary + rerun). Rendered right-aligned opposite the title.
trailing?: ReactNode
children: ReactNode
}
// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors
// the desktop PR page's card-with-header-divider so the sections read consistently.
// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the
// desktop PR page's card-with-header-divider so the sections read consistently.
// Header is omitted when neither title nor trailing is provided.
export function PRSection({ title, trailing, children }: Props) {
const showHeader = Boolean(title) || trailing != null
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionLabel}>{title}</Text>
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
{showHeader ? (
<View style={styles.sectionHeader}>
{title ? <Text style={styles.sectionLabel}>{title}</Text> : null}
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
) : null}
<View style={styles.sectionBody}>{children}</View>
</View>
)
@@ -16,11 +16,21 @@ type Props = {
details: GitHubWorkItemDetails | null
// Inline title-edit action; the pencil affordance only shows when the PR is editable.
titleAction: MobilePrTitleAction
// Hub chrome already surfaces open-on-web; hide the duplicate icon in that case.
showOpenOnWeb?: boolean
// When true, render without section chrome so identity can share a card with actions.
bare?: boolean
}
// Header: state badge (incl. draft — display-only), title, author, head->base.
// The title is inline-editable on an open/draft PR (desktop parity).
export function PRSidebarHeader({ pr, details, titleAction }: Props) {
// Compact identity: state + # + author on one meta row, title, head→base.
// # lives only in the meta row (not also after the title) to avoid repetition.
export function PRSidebarHeader({
pr,
details,
titleAction,
showOpenOnWeb = true,
bare = false
}: Props) {
const item = details?.item
const badge = prStateBadge(pr.state)
const badgeColor = statusColor(badge.token)
@@ -29,14 +39,12 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
const baseRef = item?.baseRefName ?? null
const headRef = item?.branchName ?? null
const editable = canEditPRTitle(pr.state)
// Badge, #number, and the flush-right external-link control all open pr.url
// (canonical host web URL) in the phone browser.
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
return (
<View style={styles.section}>
<View style={styles.sectionBody}>
<View style={styles.badgeRow}>
const body = (
<>
<View style={styles.metaRow}>
<View style={styles.metaLeft}>
<Pressable
onPress={openPr}
disabled={!openPr}
@@ -50,50 +58,60 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
{openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} in browser`}
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
<Text
style={styles.prMetaStrong}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
>
#{pr.number}
</Text>
{author ? <Text style={styles.prMeta}>· {author}</Text> : null}
</View>
<PRTitle
title={title}
number={pr.number}
editable={editable}
openPr={openPr}
titleAction={titleAction}
/>
{author ? <Text style={styles.prMeta}>by {author}</Text> : null}
{baseRef && headRef ? (
// head -> base reads in merge direction (desktop ChecksPanel parity).
<View style={styles.branchRow}>
<Text style={styles.branchPill}>{headRef}</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill}>{baseRef}</Text>
</View>
{showOpenOnWeb && openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} in browser`}
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
<PRTitle title={title} editable={editable} titleAction={titleAction} />
{baseRef && headRef ? (
<View style={styles.branchRow}>
<Text style={styles.branchPill} numberOfLines={1}>
{headRef}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill} numberOfLines={1}>
{baseRef}
</Text>
</View>
) : null}
</>
)
if (bare) {
return <View style={styles.identityBlock}>{body}</View>
}
return (
<View style={styles.section}>
<View style={styles.sectionBody}>{body}</View>
</View>
)
}
function PRTitle({
title,
number,
editable,
openPr,
titleAction
}: {
title: string
number: number
editable: boolean
openPr: (() => void) | undefined
titleAction: MobilePrTitleAction
}) {
const [editing, setEditing] = useState(false)
@@ -119,7 +137,7 @@ function PRTitle({
if (editing) {
return (
<View>
<View style={composerStyles.container}>
<TextInput
style={composerStyles.input}
value={draft}
@@ -165,17 +183,7 @@ function PRTitle({
accessibilityRole={editable ? 'button' : undefined}
accessibilityLabel={editable ? 'Edit pull request title' : undefined}
>
<Text style={styles.prTitle}>
{title}{' '}
<Text
style={styles.prMeta}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${number} on the web`}
>
#{number}
</Text>
</Text>
<Text style={styles.prTitle}>{title}</Text>
{editable ? (
<View style={styles.titleEditButton}>
<Pencil size={14} color={colors.textSecondary} strokeWidth={2} />
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubAssignableUser } from '../../../../src/shared/types'
@@ -26,9 +26,10 @@ type LoadState =
| { status: 'error'; message: string }
| { status: 'loaded'; users: GitHubAssignableUser[] }
// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new
// primitive). Seeded with already-requested/latest reviewers + author at the top.
// Optimistic add/remove via onToggle.
// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList):
// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in
// that ScrollView throws and can leave the Reviewers section blank.
// Seeded reviewers + author sort first for quick un-request.
export function ReviewerPickerDrawer({
visible,
onClose,
@@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({
}
let cancelled = false
setLoad({ status: 'loading' })
void fetchAssignableUsers(client, worktreeId).then((outcome) => {
if (cancelled) {
return
}
setLoad(
outcome.ok
? { status: 'loaded', users: outcome.result }
: { status: 'error', message: outcome.error }
)
})
void fetchAssignableUsers(client, worktreeId)
.then((outcome) => {
if (cancelled) {
return
}
setLoad(
outcome.ok
? { status: 'loaded', users: outcome.result }
: { status: 'error', message: outcome.error }
)
})
.catch(() => {
if (!cancelled) {
setLoad({ status: 'error', message: 'Failed to load people' })
}
})
return () => {
cancelled = true
}
@@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({
<Text style={styles.emptyText}>No matching people</Text>
</View>
) : (
<FlatList
style={styles.pickerList}
data={ordered}
keyExtractor={(u) => u.login}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
<View style={styles.pickerList}>
{ordered.map((item) => {
const requested = isRequested(item.login)
return (
<Pressable
key={item.login}
style={styles.pickerRow}
onPress={() => onToggle(item.login)}
accessibilityRole="button"
@@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({
</View>
</Pressable>
)
}}
/>
})}
</View>
)}
</BottomDrawer>
)
@@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
section: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
overflow: 'hidden'
borderBottomColor: colors.borderSubtle
},
// Section header row: title + optional trailing control, divided from the body
// by a hairline border (desktop `h-10 border-b px-3`).
@@ -55,14 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '600'
},
// Header section: state badge, title, author, base<-head branches.
// Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
badgeRow: {
// Identity + actions share one section card (hub redesign): no per-block chrome.
identityBlock: {
gap: spacing.sm
},
// State badge + #number + author on one row; open-on-web flush right when shown.
metaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
metaLeft: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: spacing.xs
},
badge: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
@@ -76,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontWeight: '700'
},
prTitle: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '700',
@@ -85,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
// #number in the meta row — stronger than author so the identity scans first.
prMetaStrong: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
// Title row: tappable area pairing the title with a trailing edit affordance.
titleRow: {
flexDirection: 'row',
@@ -104,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({
gap: spacing.xs
},
branchPill: {
flexShrink: 1,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
backgroundColor: colors.bgPanel,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: radii.button
@@ -146,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
// Loading / empty status row inside Reviewers (and similar) section bodies.
reviewersStatus: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
summaryLabel: {
fontSize: typography.bodySize,
fontWeight: '700'
@@ -274,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: typography.bodySize,
marginBottom: spacing.sm
},
// No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we
// never nest a VirtualizedList inside the PR page ScrollView.
pickerList: {
maxHeight: 320
gap: 0
},
pickerRow: {
minHeight: 44,
@@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// line). Split out of mobile-pr-sidebar-styles to keep that file under the
// 300-line cap.
export const prActionsStyles = StyleSheet.create({
// Bare block when identity + actions share one section card.
actionsBlock: {
gap: spacing.sm
},
// Close/Reopen + Unlink share a row so secondary actions don't stack full-width.
secondaryRow: {
flexDirection: 'row',
alignItems: 'stretch',
gap: spacing.sm
},
secondaryButton: {
flex: 1
},
// Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add).
actionButton: {
minHeight: 44,
@@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// match the PR comment timeline; split out to keep PRCommentComposer focused.
export const prCommentComposerStyles = StyleSheet.create({
container: {
gap: spacing.sm
// Input → Cancel/Save needs clear separation (title edit was flush without this).
gap: spacing.md
},
input: {
minHeight: 64,
+17
View File
@@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => {
expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }])
})
it('accepts raw gh latestReviews author.login nesting', () => {
const parsed = readWorkItemDetails({
item: {
id: 'n',
type: 'pr',
number: 1,
state: 'open',
latestReviews: [
{ author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' }
]
}
})
expect(parsed?.item.latestReviews).toEqual([
{ login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' }
])
})
it('returns null when item is unparseable', () => {
expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull()
expect(readWorkItemDetails(null)).toBeNull()
+11 -2
View File
@@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null
if (!isRecord(value)) {
return null
}
const login = readString(value.login)
// Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json`
// keeps nested `author.login` — accept both so mobile never drops reviewers.
const nestedAuthor = isRecord(value.author) ? value.author : null
const login =
readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined)
if (login === undefined) {
return null
}
const avatarUrl =
readString(value.avatarUrl) ??
(nestedAuthor
? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null)
: null)
return {
login,
state: readString(value.state) ?? null,
avatarUrl: readString(value.avatarUrl) ?? null
avatarUrl
}
}
@@ -157,6 +157,7 @@ export function resolvePrSidebarDetailsAfterPhase2(args: {
}
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
// The synthetic id is the only id shape we mint — real GitHub node ids never match.
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
return {
item: {
@@ -175,6 +176,20 @@ export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
}
}
// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews).
export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean {
return details.item.id === `pr-${details.item.number}`
}
// Phase-2 still needs a real payload: null (in flight / not started) or a
// synthetic placeholder from a failed fetch. Real details (including empty body)
// do not qualify — ensure/retry must not re-pull those.
export function prSidebarDetailsNeedFetch(
details: GitHubWorkItemDetails | null | undefined
): boolean {
return details == null || isPrSidebarDetailsPlaceholder(details)
}
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
// no visible/in-flight sidebar work (hidden or terminal error states).
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
@@ -41,6 +41,8 @@ describe('mobile session startup', () => {
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await fetchSessionTabs()')
)
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
})
it('activates an already-selected pending terminal tab after hydration', () => {
@@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle
expect(engine.error).toBe('socket hung up')
expect(engine.busy).toBeNull()
})
it('surfaces a refetch throw as error without rejecting the action promise', async () => {
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: async () => {
throw new Error('refresh failed')
},
onChange: () => {}
})
await expect(engine.merge()).resolves.toBeUndefined()
expect(engine.error).toBe('refresh failed')
expect(engine.busy).toBeNull()
})
it('does not notify on no-op setError(null) at action start', async () => {
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: () => {},
onChange
})
// Idle: error is already null. Action start must only notify for busy, not a
// redundant clearError path.
onChange.mockClear()
const p = engine.merge()
// First notify is setBusy only (setError(null) no-ops).
expect(onChange).toHaveBeenCalledTimes(1)
await p
})
})
describe('PrActionsEngine — PR identity changes', () => {
+20 -1
View File
@@ -129,7 +129,12 @@ export class PrActionsEngine {
return f
}
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
// so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) {
return
}
this.busy = key
this.cfg.onChange()
}
@@ -143,6 +148,9 @@ export class PrActionsEngine {
}
private setError(message: string | null): void {
if (this.error === message) {
return
}
this.error = message
this.cfg.onChange()
}
@@ -180,7 +188,14 @@ export class PrActionsEngine {
}
if (outcome.ok) {
handlers.onSuccess()
await this.cfg.refetch()
// Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox.
try {
await this.cfg.refetch()
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.')
}
}
return
}
// Both failure classes clear optimism to authoritative; only the message
@@ -205,6 +220,10 @@ export class PrActionsEngine {
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.')
}
} finally {
this.clearBusyIfOwned(identity, { kind: 'merge' })
}
@@ -5,6 +5,8 @@ import type { GitHubPrReadOutcome } from './github-pr-rpc'
import {
classifyPrSidebarFailure,
emptyPrSidebarDetails,
isPrSidebarDetailsPlaceholder,
prSidebarDetailsNeedFetch,
loadPrSidebarData,
loadPrSidebarDetails,
resolvePrSidebarDetailsAfterPhase2,
@@ -240,6 +242,29 @@ describe('resolvePrSidebarDetailsAfterPhase2', () => {
expect(empty.comments).toEqual([])
expect(empty.item.number).toBe(7)
expect(empty.item.type).toBe('pr')
expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true)
})
})
describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => {
it('detects the synthetic hyphen id, not the host pr:n shape', () => {
const placeholder = emptyPrSidebarDetails(PR)
expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true)
expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false)
// Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`).
expect(
isPrSidebarDetailsPlaceholder({
...DETAILS,
item: { ...DETAILS.item, id: `pr:${PR.number}` }
})
).toBe(false)
})
it('needs fetch for null and placeholders, not real details', () => {
expect(prSidebarDetailsNeedFetch(null)).toBe(true)
expect(prSidebarDetailsNeedFetch(undefined)).toBe(true)
expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true)
expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false)
})
})
@@ -11,6 +11,7 @@ import {
import {
loadPrSidebarData,
loadPrSidebarDetails,
prSidebarDetailsNeedFetch,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
@@ -103,12 +104,21 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (!probeReady || !client) {
return
}
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
setRepoProbeLoaded(true)
}
})
void fetchGithubRepoSlug(client, worktreeId)
.then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
setRepoProbeLoaded(true)
}
})
.catch(() => {
// Why: sendGithubPrRead already normalizes throws, but a cancelled
// unmount + any unexpected rejection must not surface as LogBox.
if (!cancelled) {
setIsGithubRepo(false)
setRepoProbeLoaded(true)
}
})
return () => {
cancelled = true
}
@@ -228,9 +238,12 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
// Retries synthetic placeholders too: a failed phase-2 installs non-null empty
// details so Description/Comments leave the spinner, and without this ensure
// would never re-fetch on tab re-open.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
if (current.kind !== 'ready' || current.data.details != null) {
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
return
}
const deps = buildDeps()
@@ -303,7 +316,8 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
(state.kind !== 'ready' && state.kind !== 'loading')
) {
void load({ includeDetails: true })
} else if (state.kind === 'ready' && state.data.details == null) {
} else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) {
// Retries phase-2 placeholders too (failed details load, not only null).
void ensurePrSidebarDetails()
}
}, [identity, state, load, ensurePrSidebarDetails])
@@ -16,6 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
@@ -177,8 +178,11 @@ export function MobileSourceControlPanel({
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
// Placeholder details (failed phase 2) also count as missing so reopening the
// PR tab retries instead of leaving empty Description/Comments forever.
const prDetailsMissingFor =
prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
prController.prSidebarState.kind === 'ready' &&
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
? prController.prSidebarState.data.pr.number
: null
@@ -299,6 +303,8 @@ export function MobileSourceControlPanel({
// Controller + chip state still live for instant re-open without a full cold start.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
// Git status always reports a conflictOperation enum; 'unknown' means none.
const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown'
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
return (
@@ -313,7 +319,11 @@ export function MobileSourceControlPanel({
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{ready ? (
{/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
tab they duplicate the ready PR body (#, state, checks rollup, branch
trajectory), so hide the whole card there and let the PR panel own it —
unless a merge/rebase conflict is active, which only this card can abort. */}
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
syncLabel={syncLabel}
@@ -52,7 +52,7 @@ describe('buildMobileSourceControlActions', () => {
expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true)
expect(labels).toContain('Rebase onto base')
expect(labels).toContain('Switch branch')
expect(labels).toContain('History')
expect(labels).toContain('Commits')
expect(labels).toContain('Create PR')
})
@@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => {
const handlers = noopHandlers()
const actions = buildMobileSourceControlActions(args({ handlers }))
action(actions, 'Switch branch')?.onPress()
action(actions, 'History')?.onPress()
action(actions, 'Commits')?.onPress()
expect(handlers.checkout).toHaveBeenCalled()
expect(handlers.history).toHaveBeenCalled()
})
@@ -217,7 +217,7 @@ export function buildMobileSourceControlActions(
onPress: handlers.checkout
},
{
label: 'History',
label: 'Commits',
iconKey: 'history',
disabled: busy,
onPress: handlers.history
@@ -4,29 +4,29 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
// Styles for the hub's segmented control and the branch-card PR chip. Split from
// mobile-source-control-styles.ts so neither file crosses the line limit.
export const hubStyles = StyleSheet.create({
// Full-width tab bar under the hub header. Edge-to-edge, no inset track and no
// inner padding — segments share height evenly so the control doesn't float in a
// pill frame with gaps above/below the active cell.
segments: {
flexDirection: 'row',
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: 3,
borderRadius: radii.button + 2,
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
gap: 2
alignItems: 'stretch',
width: '100%',
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
segment: {
flex: 1,
minHeight: 34,
borderRadius: radii.button,
minHeight: 40,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xs
paddingHorizontal: spacing.xs,
// Reserved so active underline doesn't change layout height.
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
segmentActive: {
backgroundColor: colors.bgPanel,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
borderBottomColor: colors.textPrimary
},
segmentPressed: {
opacity: 0.7
@@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
changes: 'Changes',
pr: 'Pull Request',
history: 'History'
history: 'Commits'
}
// Normalize a route param (possibly an array from expo-router, possibly unknown)
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { headlessActivationNeedsHostRenderer } from './worktree-activation-result'
describe('headlessActivationNeedsHostRenderer', () => {
it('only reports the explicit sleeping-agent headless outcome', () => {
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'unsupported-headless' })).toBe(
true
)
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'not-applicable' })).toBe(false)
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'requested' })).toBe(false)
expect(headlessActivationNeedsHostRenderer(null)).toBe(false)
})
})
@@ -0,0 +1,8 @@
export function headlessActivationNeedsHostRenderer(result: unknown): boolean {
return Boolean(
result &&
typeof result === 'object' &&
'sleepingAgentWake' in result &&
result.sleepingAgentWake === 'unsupported-headless'
)
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.131-rc.2",
"version": "1.4.132-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -82,6 +82,7 @@
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
+152
View File
@@ -0,0 +1,152 @@
# Prod Release Scan — output.md
**Verdict:** ready with accepted P1 risk
**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`)
**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute)
**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5
**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security)
**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers)
**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified
---
## Issue table by PR / commit
### Has issues
| Ref | Title | Severity | Issue summary |
|-----|-------|----------|---------------|
| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. |
| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. |
| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. |
| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). |
| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. |
| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). |
| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. |
### No issues found (clean for this scan)
| Ref | Title | Notes |
|-----|-------|-------|
| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. |
| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. |
| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). |
| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. |
| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. |
| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. |
---
## Findings first
### P0 — Release Blocking
None.
No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span.
### P1 — Releasable Only With Explicit Acceptance
1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY**
`wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`.
*Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`.
2. **#7906 Mobile wake — double resume of same provider session**
Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record.
*Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`.
3. **#7906 Mobile wake — race during hibernation kill**
Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching.
*Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier.
4. **#7906 Perf — whole-worktree background mount**
One passive sleep record mounts every terminal tab in the worktree permanently.
*Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model.
5. **#7906 Headless mobile pairing — wake no-op**
No renderer ⇒ no wake; headless serve still returns activated.
*Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`.
6. **#7960 Missing `~/.codex` directory loses first promoted setting**
Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case.
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`.
7. **#7960 Permission widening on promote (privacy)**
Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable.
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`.
8. **#7960 WSL never gets promotion**
WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only.
*Evidence:* `runtime-home-service.ts:139-150,225-235`.
### P2 — Nice To Have
- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`).
- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys.
- **#7977** Auto-enabled experimental card cohort not rolled back.
- **#7845** STYLEGUIDE scrollbar gutter text out of date.
---
## Clean matrix
| Area | Status |
|------|--------|
| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. |
| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. |
| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. |
| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. |
| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. |
| Git providers / shared contracts | Not materially touched. |
| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. |
---
## Verification
| Check | Result |
|-------|--------|
| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed |
| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign |
| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff |
| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` |
| Missing parent repro | `ENOENT` |
| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) |
| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment |
---
## Orchestration provenance
| Worker | Task | Terminal | Report |
|--------|------|----------|--------|
| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` |
| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` |
| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` |
| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` |
| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` |
| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` |
All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch.
---
## Residual risk
- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms.
- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged.
- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk.
- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven.
---
## Bottom line
**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable:
1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab.
2. Deduplicate in-place hibernation wake vs generic resume for the same claim key.
3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`.
4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported).
Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2.
@@ -0,0 +1,82 @@
import { createCipheriv, pbkdf2Sync } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { join } from 'node:path'
type ChromiumCookieTestRow = {
name: string
value: string
encryptedValue?: Buffer
}
export function createChromiumCookieTestDatabase(
databasePath: string,
rows: ChromiumCookieTestRow[],
options: { journalMode?: 'wal' } = {}
): DatabaseSync {
mkdirSync(join(databasePath, '..'), { recursive: true })
const database = new DatabaseSync(databasePath)
if (options.journalMode === 'wal') {
database.exec('PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0')
}
database.exec(`
CREATE TABLE cookies (
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT X'',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
samesite INTEGER NOT NULL,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0,
UNIQUE(host_key, top_frame_site_key, name, path, source_scheme, source_port)
)
`)
const insert = database.prepare(`
INSERT INTO cookies (
creation_utc,
host_key,
top_frame_site_key,
name,
value,
encrypted_value,
path,
expires_utc,
is_secure,
is_httponly,
samesite,
source_scheme,
source_port,
last_update_utc,
has_cross_site_ancestor
) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0)
`)
rows.forEach((row, index) => {
insert.run(
133_000_000_000_000 + index,
'.example.com',
row.name,
row.value,
row.encryptedValue ?? Buffer.alloc(0),
0
)
})
return database
}
export function encryptMacChromiumCookie(value: string, password: string): Buffer {
const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1')
const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' '))
return Buffer.concat([
Buffer.from('v10'),
cipher.update(Buffer.from(value, 'latin1')),
cipher.final()
])
}
+182 -2
View File
@@ -1,11 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({
const {
appGetPathMock,
copyFileSyncMock,
execFileSyncMock,
sessionFromPartitionMock,
dialogShowOpenDialogMock
} = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
copyFileSyncMock: vi.fn(),
execFileSyncMock: vi.fn(),
sessionFromPartitionMock: vi.fn(),
dialogShowOpenDialogMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
return {
...actual,
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
copyFileSyncMock(...args)
return actual.copyFileSync(...args)
}
}
})
vi.mock('electron', () => ({
app: { getPath: appGetPathMock },
BrowserWindow: { fromWebContents: vi.fn() },
dialog: { showOpenDialog: dialogShowOpenDialogMock },
session: { fromPartition: sessionFromPartitionMock }
@@ -20,10 +43,26 @@ import {
type ChromiumCookieColumnInfo,
type DetectedBrowser
} from './browser-cookie-import'
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'
import {
createChromiumCookieTestDatabase,
encryptMacChromiumCookie
} from './browser-cookie-import-test-database'
import { existsSync, writeFileSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
function chromeBrowser(cookiesPath: string): DetectedBrowser {
return {
family: 'chrome',
label: 'Google Chrome',
cookiesPath,
keychainService: 'Chrome Safe Storage',
keychainAccount: 'Chrome',
profiles: [{ name: 'Default', directory: 'Default' }],
selectedProfile: 'Default'
}
}
const LARGE_SAFARI_COOKIE_COUNT = 150_000
describe('summarizeCookieImportError', () => {
@@ -327,6 +366,147 @@ describe('importCookiesFromBrowser Safari', () => {
})
})
describe('importCookiesFromBrowser Chromium', () => {
let tmpDir: string
let cookiesSetMock: ReturnType<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
let clearStorageDataMock: ReturnType<typeof vi.fn>
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
clearStorageDataMock = vi.fn().mockResolvedValue(undefined)
appGetPathMock.mockReset()
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
copyFileSyncMock.mockClear()
execFileSyncMock.mockReset()
execFileSyncMock.mockImplementation(() => {
throw new Error('OS credential commands are unavailable in this test')
})
sessionFromPartitionMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
cookies: {
set: cookiesSetMock,
remove: cookiesRemoveMock,
flushStore: cookiesFlushStoreMock
},
clearStorageData: clearStorageDataMock
})
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
// Why: keeping the WAL writer open proves import reads Chromium's live SQLite
// state instead of relying on a closed-file snapshot.
const sourceDb = createChromiumCookieTestDatabase(
sourceCookiesPath,
[{ name: 'sid', value: 'source-value' }],
{ journalMode: 'wal' }
)
createChromiumCookieTestDatabase(targetCookiesPath, [
{ name: 'old', value: 'target-value' }
]).close()
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true)
const result = await importCookiesFromBrowser(
chromeBrowser(sourceCookiesPath),
'persist:test'
)
expect(result.ok).toBe(true)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({
domain: '.example.com',
name: 'sid',
value: 'source-value'
})
)
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false)
expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe(
false
)
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
} finally {
platformSpy.mockRestore()
sourceDb.close()
}
})
it('uses the OS key for encrypted Chromium rows', async () => {
const password = 'test-password'
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{
name: 'sid',
value: '',
encryptedValue: encryptMacChromiumCookie('encrypted-value', password)
}
]).close()
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
execFileSyncMock.mockImplementation((command: string) => {
if (command === 'security') {
return `${password}\n`
}
throw new Error(`Unexpected command: ${command}`)
})
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
const result = await importCookiesFromBrowser(
chromeBrowser(sourceCookiesPath),
'persist:test'
)
expect(result.ok).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledWith(
'security',
expect.any(Array),
expect.any(Object)
)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
)
} finally {
platformSpy.mockRestore()
}
})
it('removes staging data when the OS key is unavailable', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{ name: 'sid', value: '', encryptedValue: Buffer.from('v10-encrypted') }
]).close()
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
try {
const result = await importCookiesFromBrowser(
chromeBrowser(sourceCookiesPath),
'persist:test'
)
expect(result.ok).toBe(false)
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
} finally {
platformSpy.mockRestore()
}
})
})
describe('detectInstalledBrowsers', () => {
it('returns an array of detected browsers', () => {
const browsers = detectInstalledBrowsers()
+40 -66
View File
@@ -78,6 +78,7 @@ import type {
} from '../../shared/types'
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
// ---------------------------------------------------------------------------
// Browser detection
@@ -192,20 +193,6 @@ function browserRootPath(def: ChromiumBrowserDef): string | null {
return join(configHome, def.linuxRoot)
}
// Why: Chromium 96+ moved the cookies DB from <Profile>/Cookies to
// <Profile>/Network/Cookies. Try the newer path first, fall back to legacy.
function resolveCookiesPath(profileDir: string): string | null {
const networkPath = join(profileDir, 'Network', 'Cookies')
if (existsSync(networkPath)) {
return networkPath
}
const legacyPath = join(profileDir, 'Cookies')
if (existsSync(legacyPath)) {
return legacyPath
}
return null
}
function isSafeBrowserProfileDirectory(directory: string): boolean {
return (
directory.length > 0 &&
@@ -373,7 +360,7 @@ export function detectInstalledBrowsers(): DetectedBrowser[] {
// Use the first profile with a valid cookies path as the default selection.
for (const profile of profiles) {
const profileDir = join(root, profile.directory)
const cookiesPath = resolveCookiesPath(profileDir)
const cookiesPath = resolveChromiumCookiesPath(profileDir)
if (cookiesPath) {
detected.push({
family: browser.family,
@@ -434,7 +421,7 @@ export function selectBrowserProfile(
return null
}
const profileDir = join(root, profileDirectory)
const cookiesPath = resolveCookiesPath(profileDir)
const cookiesPath = resolveChromiumCookiesPath(profileDir)
if (!cookiesPath) {
return null
}
@@ -1468,36 +1455,8 @@ export async function importCookiesFromBrowser(
return importCookiesFromSafari(browser, targetPartition)
}
// Why: the browser may hold a lock on the Cookies file. Copying to a temp
// location avoids lock contention and ensures we read a consistent snapshot.
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-'))
const tmpCookiesPath = join(tmpDir, 'Cookies')
try {
copyFileSync(browser.cookiesPath, tmpCookiesPath)
// Why: when the source browser is running, it uses WAL journal mode. The most
// recently written cookies (including fresh auth tokens) may only exist in the
// WAL sidecar file, not yet flushed to the main DB. Copying WAL + SHM ensures
// our snapshot reflects the browser's current state.
for (const suffix of ['-wal', '-shm'] as const) {
const sidecar = browser.cookiesPath + suffix
if (existsSync(sidecar)) {
try {
copyFileSync(sidecar, tmpCookiesPath + suffix)
} catch {
// Why: sidecar copy is best-effort. The main DB alone may still have
// enough cookies for a usable session; missing the WAL just means
// we might miss the very latest writes.
}
}
}
} catch {
rmSync(tmpDir, { recursive: true, force: true })
return {
ok: false,
reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.`
}
}
// Why: SQLite can coordinate with Chromium's live WAL locks without a pre-copy.
const sourceCookiesPath = browser.cookiesPath
// Why: Electron's cookies.set() API rejects many valid cookie values (binary
// bytes > 0x7F etc). Instead, decrypt from the source browser and write
@@ -1508,16 +1467,6 @@ export async function importCookiesFromBrowser(
// In packaged builds where os_crypt IS active, CookieMonster will re-encrypt
// plaintext cookies on its next flush, so this approach is safe in both modes.
// Why: only Chromium browsers reach this point — Firefox/Safari dispatched above.
const sourceKey = getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
if (!sourceKey) {
rmSync(tmpDir, { recursive: true, force: true })
return {
ok: false,
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
}
}
// Why: CookieMonster holds the live DB's data in memory and overwrites it
// on flush/shutdown. Writing directly to the live DB is futile. Instead,
// copy the live DB to a staging location, populate it there, and let the
@@ -1526,13 +1475,14 @@ export async function importCookiesFromBrowser(
await targetSession.cookies.flushStore()
const partitionName = targetPartition.replace('persist:', '')
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
let liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
// Why: Electron only creates the partition's Cookies SQLite file after the
// session has actually stored a cookie. For newly created profiles that have
// never been used by a webview, the file won't exist yet. Setting and
// removing a throwaway cookie forces Electron to initialize the database.
if (!existsSync(liveCookiesPath)) {
if (!liveCookiesPath) {
try {
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
await targetSession.cookies.remove('https://localhost', '__init')
@@ -1540,10 +1490,10 @@ export async function importCookiesFromBrowser(
} catch {
// ignore — the set/remove may fail but flushStore should still create the file
}
liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
}
if (!existsSync(liveCookiesPath)) {
rmSync(tmpDir, { recursive: true, force: true })
if (!liveCookiesPath) {
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
}
@@ -1557,7 +1507,6 @@ export async function importCookiesFromBrowser(
mkdirSync(stagingDir, { recursive: true })
copyFileSync(liveCookiesPath, stagingCookiesPath)
} catch {
rmSync(tmpDir, { recursive: true, force: true })
return { ok: false, reason: 'Could not create staging cookie database.' }
}
@@ -1567,7 +1516,7 @@ export async function importCookiesFromBrowser(
try {
// Why: Chromium stores timestamps as microseconds since 1601, which can exceed
// Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss.
sourceDb = new DatabaseSync(tmpCookiesPath, { readOnly: true, readBigInts: true })
sourceDb = new DatabaseSync(sourceCookiesPath, { readOnly: true, readBigInts: true })
stagingDb = new DatabaseSync(stagingCookiesPath)
const targetColumnInfo = stagingDb
@@ -1590,10 +1539,37 @@ export async function importCookiesFromBrowser(
if (sourceRows.length === 0) {
stagingDb.close()
stagingDb = null
rmSync(tmpDir, { recursive: true, force: true })
try {
unlinkSync(stagingCookiesPath)
} catch {
/* best-effort */
}
return { ok: false, reason: `No cookies found in ${browser.label}.` }
}
const needsSourceKey = sourceRows.some((sourceRow) => {
const encRaw = sourceRow.encrypted_value
return encRaw instanceof Uint8Array && encRaw.length > 0
})
const sourceKey = needsSourceKey
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
: null
if (needsSourceKey && !sourceKey) {
stagingDb.close()
stagingDb = null
// Why: key denial happens after staging, so failed retries must not leave
// one full target database copy behind each time.
try {
unlinkSync(stagingCookiesPath)
} catch {
/* best-effort */
}
return {
ok: false,
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
}
}
// Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP)
// are cryptographically bound to the source browser's TLS fingerprint and
// environment. Importing them into a different browser causes
@@ -1652,7 +1628,7 @@ export async function importCookiesFromBrowser(
let decryptedValue: Buffer
if (encBuf && encBuf.length > 0) {
const raw = decryptCookieValueRaw(encBuf, sourceKey)
const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null
if (!raw) {
skipped++
continue
@@ -1709,7 +1685,6 @@ export async function importCookiesFromBrowser(
stagingDb.close()
stagingDb = null
rmSync(tmpDir, { recursive: true, force: true })
diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`)
// Why: clearing the session's in-memory cookie store before loading imported
@@ -1795,7 +1770,6 @@ export async function importCookiesFromBrowser(
} catch {
/* may already be closed */
}
rmSync(tmpDir, { recursive: true, force: true })
// Why: if the import fails after the staging DB was created, clean it up
// to avoid a stale staged import being applied on the next cold start.
try {
@@ -157,6 +157,31 @@ describe('BrowserSessionRegistry persistence', () => {
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
})
it('replays pending cookies into an existing Network database', async () => {
const stagedPath = '/staged/network-import'
const networkPath = '/user-data/Partitions/orca-browser/Network/Cookies'
const legacyPath = '/user-data/Partitions/orca-browser/Cookies'
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
pendingCookieDbPath: stagedPath,
profiles: []
})
fsState.files.set(stagedPath, 'imported cookies')
fsState.files.set(networkPath, 'old cookies')
fsState.present.add(stagedPath)
fsState.present.add(networkPath)
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.applyPendingCookieImport()
expect(fsState.files.get(networkPath)).toBe('imported cookies')
expect(fsState.present.has(legacyPath)).toBe(false)
})
it('persists new browser session profiles under the active Orca profile directory', async () => {
const fsState = createFsState()
const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json'
+5 -1
View File
@@ -26,6 +26,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh
import { browserManager } from './browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
import {
allowsBrowserWebAuthnPermission,
@@ -101,7 +102,10 @@ class BrowserSessionRegistry {
private static partitionCookiesPath(partition: string): string {
const partitionName = partition.replace('persist:', '')
return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
// Why: replay must overwrite the same modern or legacy database that the
// importing Electron partition already uses.
return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies')
}
// Why: write-to-temp-then-rename is atomic on all supported platforms.
+142
View File
@@ -89,6 +89,148 @@ describe('CdpWsProxy', () => {
client.close()
})
it('routes Playwright nested browser and page sessions on the proxy path', async () => {
const client = await connect(endpoint)
const primaryPageAttachResponse = await sendAndReceive(client, {
id: 31,
method: 'Target.attachToTarget',
params: { targetId: 'orca-proxy-target', flatten: true }
})
expect(primaryPageAttachResponse).toEqual({
id: 31,
result: { sessionId: 'orca-proxy-session' }
})
const attachResponse = await sendAndReceive(client, {
id: 32,
method: 'Target.attachToBrowserTarget',
params: {}
})
expect(attachResponse).toEqual({
id: 32,
result: { sessionId: 'orca-proxy-browser-session' }
})
expect(getSendCommandMethods(mock)).not.toContain('Target.attachToBrowserTarget')
const pageAttachResponse = await sendAndReceive(client, {
id: 33,
method: 'Target.attachToTarget',
params: { targetId: 'orca-proxy-target', flatten: true },
sessionId: 'orca-proxy-browser-session'
})
expect(pageAttachResponse).toEqual({
id: 33,
result: { sessionId: 'orca-proxy-session-2' },
sessionId: 'orca-proxy-browser-session'
})
const sessionResponse = await sendAndReceive(client, {
id: 34,
method: 'Runtime.evaluate',
params: { expression: 'document.title' },
sessionId: 'orca-proxy-session-2'
})
expect(sessionResponse).toEqual({
id: 34,
result: {},
sessionId: 'orca-proxy-session-2'
})
expect(getSendCommandCalls(mock)).toContainEqual([
'Runtime.evaluate',
{ expression: 'document.title' }
])
const detachResponse = await sendAndReceive(client, {
id: 35,
method: 'Target.detachFromTarget',
params: { sessionId: 'orca-proxy-session-2' },
sessionId: 'orca-proxy-browser-session'
})
expect(detachResponse).toEqual({
id: 35,
result: {},
sessionId: 'orca-proxy-browser-session'
})
const primaryPageResponse = await sendAndReceive(client, {
id: 36,
method: 'Runtime.evaluate',
params: { expression: 'document.URL' },
sessionId: 'orca-proxy-session'
})
expect(primaryPageResponse).toEqual({
id: 36,
result: {},
sessionId: 'orca-proxy-session'
})
const browserSessionResponse = await sendAndReceive(client, {
id: 37,
method: 'Target.getTargets',
params: {},
sessionId: 'orca-proxy-browser-session'
})
expect(browserSessionResponse).toMatchObject({
id: 37,
sessionId: 'orca-proxy-browser-session'
})
client.close()
})
it('preserves Target.attachToTarget and clears the synthetic session on Target.detachFromTarget', async () => {
const client = await connect(endpoint)
const attachResponse = await sendAndReceive(client, {
id: 33,
method: 'Target.attachToTarget',
params: { targetId: 'orca-proxy-target', flatten: true }
})
expect(attachResponse).toEqual({
id: 33,
result: { sessionId: 'orca-proxy-session' }
})
const detachResponse = await sendAndReceive(client, {
id: 34,
method: 'Target.detachFromTarget',
params: { sessionId: 'orca-proxy-session' }
})
expect(detachResponse).toEqual({ id: 34, result: {} })
const rootEventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.once('message', (data) => resolve(JSON.parse(data.toString())))
})
mock.emit('message', {}, 'Runtime.executionContextCreated', { context: { id: 1 } })
const rootEvent = await rootEventPromise
expect(rootEvent).toEqual({
method: 'Runtime.executionContextCreated',
params: { context: { id: 1 } }
})
const reattachResponse = await sendAndReceive(client, {
id: 35,
method: 'Target.attachToTarget',
params: { targetId: 'orca-proxy-target', flatten: true }
})
expect(reattachResponse).toEqual({
id: 35,
result: { sessionId: 'orca-proxy-session-2' }
})
client.close()
})
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
const client = await connect(endpoint)
mock.destroy()
+81 -9
View File
@@ -19,6 +19,7 @@ export class CdpWsProxy {
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
private client: WebSocket | null = null
private readonly responseSessionIdsByClient = new WeakMap<WebSocket, Map<number, string>>()
private detachClientListeners: (() => void) | null = null
private port = 0
private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null
@@ -27,6 +28,10 @@ export class CdpWsProxy {
private attached = false
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
private clientSessionId: string | undefined = undefined
private readonly clientSessionIds = new Set<string>()
private readonly clientBrowserSessionIds = new Set<string>()
private nextClientSessionOrdinal = 0
private nextClientBrowserSessionOrdinal = 0
private readonly pdfStreams = new CdpPdfStreamStore()
constructor(private readonly webContents: WebContents) {}
@@ -59,7 +64,7 @@ export class CdpWsProxy {
const onClose = (): void => {
detach()
if (this.client === ws) {
this.pdfStreams.clear()
this.clearClientState()
this.client = null
}
}
@@ -110,18 +115,45 @@ export class CdpWsProxy {
this.detachClientListeners?.()
this.detachClientListeners = null
this.client = null
// Why: a pending focus belongs to the departing client; never replay it for the next one.
this.pendingDomFocusBySession.clear()
this.pdfStreams.clear()
this.clearClientState()
if (client) {
this.responseSessionIdsByClient.delete(client)
}
client?.close()
}
private clearClientState(): void {
// Why: session and focus state belongs to one websocket and must not cross client replacement.
this.pendingDomFocusBySession.clear()
this.pdfStreams.clear()
this.clientSessionId = undefined
this.clientSessionIds.clear()
this.clientBrowserSessionIds.clear()
this.nextClientSessionOrdinal = 0
this.nextClientBrowserSessionOrdinal = 0
}
private send(payload: unknown, client = this.client): void {
const responsePayload = client ? this.addResponseSessionId(payload, client) : payload
if (client?.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(payload))
client.send(JSON.stringify(responsePayload))
}
}
private addResponseSessionId(payload: unknown, client: WebSocket): unknown {
if (typeof payload !== 'object' || payload === null) {
return payload
}
const clientId = (payload as { id?: unknown }).id
if (typeof clientId !== 'number') {
return payload
}
const responseSessionIds = this.responseSessionIdsByClient.get(client)
const sessionId = responseSessionIds?.get(clientId)
responseSessionIds?.delete(clientId)
return sessionId ? { ...payload, sessionId } : payload
}
private sendResult(clientId: number, result: unknown, client = this.client): void {
this.send({ id: clientId, result }, client)
}
@@ -253,6 +285,13 @@ export class CdpWsProxy {
return
}
const clientId = msg.id
const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map()
if (msg.sessionId) {
responseSessionIds.set(clientId, msg.sessionId)
} else {
responseSessionIds.delete(clientId)
}
this.responseSessionIdsByClient.set(client, responseSessionIds)
if (msg.method === 'Target.getTargets') {
this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client)
@@ -264,14 +303,30 @@ export class CdpWsProxy {
}
if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') {
if (msg.method === 'Target.detachFromTarget') {
this.clientSessionId = undefined
const detachedSessionId = msg.params?.sessionId
if (typeof detachedSessionId === 'string') {
this.clientSessionIds.delete(detachedSessionId)
this.clientBrowserSessionIds.delete(detachedSessionId)
if (detachedSessionId === this.clientSessionId) {
this.clientSessionId = this.clientSessionIds.values().next().value
}
}
}
this.sendResult(clientId, {}, client)
return
}
if (msg.method === 'Target.attachToBrowserTarget') {
// Why: Playwright needs a distinct browser session before it attaches to the selected page.
const sessionId = this.nextSyntheticBrowserSessionId()
this.clientBrowserSessionIds.add(sessionId)
this.sendResult(clientId, { sessionId }, client)
return
}
if (msg.method === 'Target.attachToTarget') {
this.clientSessionId = 'orca-proxy-session'
this.sendResult(clientId, { sessionId: this.clientSessionId }, client)
const sessionId = this.nextSyntheticPageSessionId()
this.clientSessionIds.add(sessionId)
this.clientSessionId ??= sessionId
this.sendResult(clientId, { sessionId }, client)
return
}
if (msg.method === 'Browser.getVersion') {
@@ -363,7 +418,24 @@ export class CdpWsProxy {
}
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
const syntheticSession =
(msgSessionId && this.clientSessionIds.has(msgSessionId)) ||
(msgSessionId && this.clientBrowserSessionIds.has(msgSessionId))
return msgSessionId && !syntheticSession ? msgSessionId : undefined
}
private nextSyntheticPageSessionId(): string {
this.nextClientSessionOrdinal += 1
return this.nextClientSessionOrdinal === 1
? 'orca-proxy-session'
: `orca-proxy-session-${this.nextClientSessionOrdinal}`
}
private nextSyntheticBrowserSessionId(): string {
this.nextClientBrowserSessionOrdinal += 1
return this.nextClientBrowserSessionOrdinal === 1
? 'orca-proxy-browser-session'
: `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}`
}
private isActiveClient(client: WebSocket): boolean {
+13
View File
@@ -0,0 +1,13 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
// Why: Chromium 96+ moved the cookie DB under Network, while older profiles
// still use the profile root. Every reader and replay writer must agree.
export function resolveChromiumCookiesPath(profileDir: string): string | null {
const networkPath = join(profileDir, 'Network', 'Cookies')
if (existsSync(networkPath)) {
return networkPath
}
const legacyPath = join(profileDir, 'Cookies')
return existsSync(legacyPath) ? legacyPath : null
}
+313
View File
@@ -165,6 +165,319 @@ describe('scanClaudeUsageFiles', () => {
expect(second.dailyAggregates[0]?.inputTokens).toBe(999)
})
it('dedupes fork copies when only message.id is present (no requestId)', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
const turn = (sessionId: string, messageId: string, inputTokens: number): string =>
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: '2026-04-09T10:00:00.000Z',
cwd: '/workspace/repo-a',
message: {
id: messageId,
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(originalFile, turn('session-1', 'msg_1', 100))
await writeFile(
forkFile,
[turn('session-2', 'msg_1', 100), turn('session-2', 'msg_2', 50)].join('\n')
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const result = await scanClaudeUsageFiles([])
expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(150)
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg:msg_1'])
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg:msg_2'])
})
it('dedupes fork copies via uuid when message ids are absent', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
const turn = (sessionId: string, uuid: string, inputTokens: number): string =>
JSON.stringify({
type: 'assistant',
sessionId,
uuid,
timestamp: '2026-04-09T10:00:00.000Z',
cwd: '/workspace/repo-a',
message: {
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(originalFile, turn('session-1', 'uuid-1', 100))
await writeFile(
forkFile,
[turn('session-2', 'uuid-1', 100), turn('session-2', 'uuid-2', 40)].join('\n')
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const result = await scanClaudeUsageFiles([])
expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(140)
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['uuid:uuid-1'])
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['uuid:uuid-2'])
})
it('counts turns copied into forked session files exactly once', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
const turn = (
sessionId: string,
messageId: string,
requestId: string,
inputTokens: number
): string =>
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: '2026-04-09T10:00:00.000Z',
requestId,
cwd: '/workspace/repo-a',
message: {
id: messageId,
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(
originalFile,
[turn('session-1', 'msg_1', 'req_1', 100), turn('session-1', 'msg_2', 'req_2', 200)].join(
'\n'
)
)
// Fork copies the original history with preserved message/request IDs but a
// rewritten sessionId, then appends its own new turn.
await writeFile(
forkFile,
[
turn('session-2', 'msg_1', 'req_1', 100),
turn('session-2', 'msg_2', 'req_2', 200),
turn('session-2', 'msg_3', 'req_3', 400)
].join('\n')
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const result = await scanClaudeUsageFiles([])
const totalInput = result.dailyAggregates.reduce(
(sum, aggregate) => sum + aggregate.inputTokens,
0
)
expect(totalInput).toBe(700)
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_2:req_2'])
expect(result.processedFiles[0]?.hasDeferredClaims).toBe(false)
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_3:req_3'])
expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true)
})
it('keeps fork dedupe stable when the original file projection is reused from cache', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'zzzz-fork.jsonl')
const turn = (
sessionId: string,
messageId: string,
requestId: string,
inputTokens: number
): string =>
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: '2026-04-09T10:00:00.000Z',
requestId,
cwd: '/workspace/repo-a',
message: {
id: messageId,
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const first = await scanClaudeUsageFiles([])
expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1'])
// A fork appears later while the original stays unchanged (cache reuse).
await writeFile(
forkFile,
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
)
const second = await scanClaudeUsageFiles([], first.processedFiles)
const totalInput = second.dailyAggregates.reduce(
(sum, aggregate) => sum + aggregate.inputTokens,
0
)
expect(totalInput).toBe(150)
expect(second.processedFiles.map((file) => file.ownedDedupeKeys)).toEqual([
['msg_1:req_1'],
['msg_9:req_9']
])
// Rescanning with the full cache stays stable.
const third = await scanClaudeUsageFiles([], second.processedFiles)
expect(third.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
150
)
})
it('reclaims fork-copied turns when the owning original file is deleted', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
const turn = (
sessionId: string,
messageId: string,
requestId: string,
inputTokens: number
): string =>
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: '2026-04-09T10:00:00.000Z',
requestId,
cwd: '/workspace/repo-a',
message: {
id: messageId,
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
await writeFile(
forkFile,
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const first = await scanClaudeUsageFiles([])
expect(first.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
150
)
expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1'])
expect(first.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_9:req_9'])
// Deleting the owner must reparse deferred forks so they can re-claim the
// copied turn instead of permanently under-counting.
await rm(originalFile)
const second = await scanClaudeUsageFiles([], first.processedFiles)
expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
150
)
expect(second.processedFiles).toHaveLength(1)
expect(second.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_9:req_9'])
})
it('keeps unrelated cached transcripts when a different owner file is deleted', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const originalFile = join(projectDir, 'aaaa-original.jsonl')
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
const unrelatedFile = join(projectDir, 'cccc-unrelated.jsonl')
const turn = (
sessionId: string,
messageId: string,
requestId: string,
inputTokens: number
): string =>
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: '2026-04-09T10:00:00.000Z',
requestId,
cwd: '/workspace/repo-a',
message: {
id: messageId,
model: 'claude-sonnet-4-6',
usage: { input_tokens: inputTokens, output_tokens: 10 }
}
})
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
await writeFile(
forkFile,
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
)
await writeFile(unrelatedFile, turn('session-3', 'msg_u', 'req_u', 25))
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const first = await scanClaudeUsageFiles([])
const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedFile)
expect(unrelatedBefore?.hasDeferredClaims).toBe(false)
expect(unrelatedBefore?.ownedDedupeKeys).toEqual(['msg_u:req_u'])
await rm(originalFile)
const second = await scanClaudeUsageFiles([], first.processedFiles)
const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedFile)
// Unrelated files must keep their cached projection identity (stat-only path).
expect(unrelatedAfter).toBe(unrelatedBefore)
expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
175
)
})
it('canonicalizes repeated cwd paths once per scan file', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
+123 -33
View File
@@ -29,6 +29,8 @@ type ClaudeUsageSourceRecord = {
cwd?: string
gitBranch?: string
requestId?: string
/** Stable row id when present; preserved across fork-copied history. */
uuid?: string
isSidechain?: boolean
agentId?: string
message?: {
@@ -209,9 +211,11 @@ function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsa
}
}
function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] {
function dedupeClaudeUsageTurns(
turns: ClaudeUsageParsedSourceTurn[]
): ClaudeUsageParsedSourceTurn[] {
const dedupeIndexByKey = new Map<string, number>()
const deduped: ClaudeUsageParsedTurn[] = []
const deduped: ClaudeUsageParsedSourceTurn[] = []
for (const turn of turns) {
if (turn.dedupeKey) {
@@ -228,8 +232,7 @@ function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsa
}
}
const stripped = stripClaudeSourceMetadata(turn)
deduped.push(stripped)
deduped.push({ ...turn })
if (turn.dedupeKey) {
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
}
@@ -273,8 +276,10 @@ function parseClaudeUsageSourceRecord(
model: parsed.message?.model ?? null,
cwd: parsed.cwd ?? null,
gitBranch: parsed.gitBranch ?? null,
dedupeKey:
parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null,
// Why: forks rewrite sessionId but keep message/request ids (and usually
// uuid). Prefer the strongest stable identity available so ownership still
// works when requestId is missing on older or partial rows.
dedupeKey: buildClaudeUsageDedupeKey(parsed),
inputTokens,
outputTokens,
cacheReadTokens,
@@ -282,6 +287,22 @@ function parseClaudeUsageSourceRecord(
}
}
function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null {
const messageId = parsed.message?.id?.trim()
const requestId = parsed.requestId?.trim()
if (messageId && requestId) {
return `${messageId}:${requestId}`
}
if (messageId) {
return `msg:${messageId}`
}
const uuid = parsed.uuid?.trim()
if (uuid) {
return `uuid:${uuid}`
}
return null
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
const parsed = parseClaudeUsageSourceRecord(line)
return parsed ? stripClaudeSourceMetadata(parsed) : null
@@ -302,12 +323,12 @@ export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsag
}
}
return dedupeClaudeUsageTurns(turns)
return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata)
}
async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: ClaudeUsageProcessedFile
turns: ClaudeUsageParsedTurn[]
turns: ClaudeUsageParsedSourceTurn[]
}> {
const fileStat = await stat(filePath)
let lineCount = 0
@@ -597,18 +618,6 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
}
}
async function parseClaudeUsagePersistedFile(
filePath: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsagePersistedFile> {
const { processedFile, turns } = await readClaudeUsageScanFile(filePath)
const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup)
return {
...processedFile,
...aggregateClaudeUsage(attributed)
}
}
export async function scanClaudeUsageFiles(
worktrees: ClaudeUsageWorktreeRef[],
previousProcessedFiles: ClaudeUsagePersistedFile[] = []
@@ -619,41 +628,122 @@ export async function scanClaudeUsageFiles(
}> {
const files = await listClaudeTranscriptFiles()
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const processedFiles: ClaudeUsagePersistedFile[] = []
const worktreeLookup = await buildWorktreeLookup(worktrees)
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
const currentPaths = new Set(files)
// Why: when a file that owned dedupe keys is deleted, remaining forks still
// contain those turns but their caches record them as unowned. Only files
// that previously deferred claims can reclaim, so invalidate those — not the
// entire transcript corpus (histories can be gigabytes).
const lostOwnerPath = previousProcessedFiles.some(
(file) =>
!currentPaths.has(file.path) &&
Array.isArray(file.ownedDedupeKeys) &&
file.ownedDedupeKeys.length > 0
)
const reusedByPath = new Map<string, ClaudeUsagePersistedFile>()
const pathsToParse: string[] = []
for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) {
const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE)
const results = await Promise.all(
const reusable = await Promise.all(
batch.map(async (filePath) => {
const fileInfo = await getProcessedFileStat(filePath)
const previous = previousByPath.get(filePath)
// Why: Claude histories can be gigabytes. Unchanged files should pay
// only stat cost on refresh while preserving exactly the old projection.
// When an owner disappears, only deferred-claim files need reparse.
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
const canReuse =
!mustReclaimDeferred &&
previous &&
previous.mtimeMs === fileInfo.mtimeMs &&
previous.size === fileInfo.size &&
Array.isArray(previous.sessions) &&
Array.isArray(previous.dailyAggregates)
return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup)
Array.isArray(previous.dailyAggregates) &&
Array.isArray(previous.ownedDedupeKeys) &&
typeof previous.hasDeferredClaims === 'boolean'
return canReuse ? previous : null
})
)
for (const processed of results) {
processedFiles.push(processed)
mergeClaudeSessions(sessionsById, processed.sessions)
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
for (const [batchIndex, previous] of reusable.entries()) {
if (previous) {
reusedByPath.set(batch[batchIndex], previous)
} else {
pathsToParse.push(batch[batchIndex])
}
}
// Why: transcript scans run in Electron's main process. Small parallel
// batches cut independent file I/O without letting Settings stay blocked.
if (index + batch.length < files.length) {
await yieldToEventLoop()
}
}
// Why: resuming or forking a Claude session copies earlier turns — with their
// original message/request IDs — into a new transcript file under a new
// session id. Per-file dedupe cannot see those copies, so long-lived sessions
// get re-counted on every fork (issue #8006). Cross-file ownership counts each
// turn for exactly one file; cached files keep the claims they persisted.
const turnOwnerByDedupeKey = new Map<string, string>()
for (const [filePath, previous] of reusedByPath) {
for (const dedupeKey of previous.ownedDedupeKeys) {
// First cached claim wins so conflicting projections stay deterministic.
if (!turnOwnerByDedupeKey.has(dedupeKey)) {
turnOwnerByDedupeKey.set(dedupeKey, filePath)
}
}
}
const parsedByPath = new Map<string, ClaudeUsagePersistedFile>()
for (let index = 0; index < pathsToParse.length; index += FILE_SCAN_BATCH_SIZE) {
const batch = pathsToParse.slice(index, index + FILE_SCAN_BATCH_SIZE)
// Why: transcript scans run in Electron's main process. Small parallel
// batches cut independent file I/O without letting Settings stay blocked.
const reads = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath)))
for (const [batchIndex, filePath] of batch.entries()) {
const { processedFile, turns } = reads[batchIndex]
// Why: ownership claims must be sequential in sorted-path order so
// rescans assign duplicated turns to the same file deterministically.
const ownedTurns: ClaudeUsageParsedTurn[] = []
const ownedDedupeKeys: string[] = []
let hasDeferredClaims = false
for (const turn of turns) {
if (turn.dedupeKey) {
const owner = turnOwnerByDedupeKey.get(turn.dedupeKey)
if (owner !== undefined && owner !== filePath) {
hasDeferredClaims = true
continue
}
turnOwnerByDedupeKey.set(turn.dedupeKey, filePath)
ownedDedupeKeys.push(turn.dedupeKey)
}
ownedTurns.push(stripClaudeSourceMetadata(turn))
}
const attributed = await attributeClaudeUsageTurns(ownedTurns, worktreeLookup)
parsedByPath.set(filePath, {
...processedFile,
...aggregateClaudeUsage(attributed),
ownedDedupeKeys,
hasDeferredClaims
})
}
if (index + batch.length < pathsToParse.length) {
await yieldToEventLoop()
}
}
const processedFiles: ClaudeUsagePersistedFile[] = []
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
for (const filePath of files) {
const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath)
if (!processed) {
continue
}
processedFiles.push(processed)
mergeClaudeSessions(sessionsById, processed.sessions)
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
return {
processedFiles,
sessions: finalizeClaudeSessions(sessionsById),
+4 -1
View File
@@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
const SCHEMA_VERSION = 3
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
// after fork reclaim (#8006).
const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
+8
View File
@@ -66,6 +66,14 @@ export type ClaudeUsagePersistedState = {
export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
/** Dedupe keys (message.id:requestId) this file counted. Forked/resumed
* sessions copy earlier turns into new files; ownership keeps each turn
* counted by exactly one cached file across incremental scans. */
ownedDedupeKeys: string[]
/** True when this file saw turns already claimed by another file. When that
* owner disappears, only deferred files need reparse to reclaim — not the
* entire transcript corpus. */
hasDeferredClaims: boolean
}
export type ClaudeUsageParsedTurn = {
@@ -533,6 +533,74 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('promotes WSL in-Codex setting changes on the next Codex launch', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve())
}))
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const store = createStore(
createSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
})
)
const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml')
mkdirSync(join(wslHome, '.codex'), { recursive: true })
writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8')
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
// First launch seeds the runtime config and records the per-distro baseline.
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
expect(existsSync(baselinePath)).toBe(true)
// A direct WSL Codex edit wins and is mirrored into Orca's runtime before
// the baseline advances, so later in-Orca changes remain promotable.
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')
// Codex now persists a /model change inside Orca's reconciled runtime.
writeFileSync(
runtimeConfigPath,
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'),
'utf-8'
)
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
// Baseline advances so the promoted value is not re-promoted forever.
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('bridges WSL history from a configured per-distro source-home override', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -1892,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => {
it('reads WSL system-default rate limits from the live system home without materializing', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
@@ -1910,10 +1978,21 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
const syncWslRuntime = vi.spyOn(
service as unknown as {
syncWslRuntimeForCurrentSelection: (target: {
runtime: 'wsl'
wslDistro?: string | null
}) => string | null
},
'syncWslRuntimeForCurrentSelection'
)
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
const expectedHome = join(wslHome, '.codex')
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
expect(syncWslRuntime).not.toHaveBeenCalled()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -2041,20 +2120,23 @@ describe('CodexRuntimeHomeService', () => {
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
systemCodexHomePath
)
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth)
const externallyRefreshedAuth = createCodexAuthJson(
'wsl@example.com',
'acct-wsl',
'system-refreshed',
3_000
)
writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8')
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
systemCodexHomePath
)
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(
externallyRefreshedAuth
)
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -139,9 +139,9 @@ export class CodexRuntimeHomeService {
prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const runtimeHomePath =
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
this.getWslSystemCodexHomePath(wslTarget)
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath)
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
return runtimeHomePath
}
@@ -222,13 +222,33 @@ export class CodexRuntimeHomeService {
return home ? this.joinWslPath(home, '.codex') : null
}
// Why: WSL needs the same promote-then-mirror transaction as host. It keeps
// in-Orca changes while reconciling a newer external ~/.codex edit before
// advancing the per-distro baseline. Only runs on a materialized runtime.
private syncWslConfigSettingsForLaunch(
target: CodexAccountSelectionTarget,
runtimeHomePath: string | null
): void {
if (!runtimeHomePath) {
return
}
const distro =
parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro()
if (!distro) {
return
}
const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (!systemHomePath || systemHomePath === runtimeHomePath) {
return
}
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
}
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
return (
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
this.getWslSystemCodexHomePath(wslTarget)
)
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
@@ -503,6 +523,32 @@ export class CodexRuntimeHomeService {
return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null
}
private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
const distro = target.wslDistro?.trim()
if (distro) {
const settings = this.store.getSettings()
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
if (selectedAccountId === null) {
// Why: the system-default account changes outside Orca (login, logout,
// token refresh). Read its real home directly so a cached runtime copy
// cannot stay stale; filesystem probing in the fetcher is asynchronous.
return this.getWslSystemCodexHomePath(target)
}
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
if (
cachedRuntimeHomePath &&
this.lastSyncedWslAccountIdByDistro.has(distro) &&
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
) {
// Why: RateLimitService resolves provenance twice per poll. Account
// changes sync explicitly, so repeated resolution must stay path-only
// instead of blocking main on UNC reads and a wsl.exe migration probe.
return cachedRuntimeHomePath
}
}
return this.syncWslRuntimeForCurrentSelection(target)
}
private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
if (process.platform !== 'win32') {
return null
+219 -1
View File
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import { join } from 'node:path'
@@ -300,6 +308,216 @@ describe('listCodexSessionFiles', () => {
).toBe(3)
})
it('counts token events copied into forked rollout files exactly once', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
const copiedPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
].join('')
writeFileSync(originalPath, copiedPrefix, 'utf-8')
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
const result = await scanCodexUsageFiles([], [])
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0)
).toBe(3)
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]?.totalTokens).toBe(22)
})
it('dedupes fork copies even when session_meta id is rewritten', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
const originalPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-original', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
].join('')
// Same token_count records, different session_meta id (real resume/fork shape).
const forkBody = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-fork', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15),
usageRecord('2026-05-26T12:02:00.000Z', 7, 22)
].join('')
writeFileSync(originalPath, originalPrefix, 'utf-8')
writeFileSync(forkPath, forkBody)
const result = await scanCodexUsageFiles([], [])
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(result.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
expect(result.processedFiles[1]?.ownedEventKeys).toHaveLength(1)
expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true)
})
it('does not re-count the cumulative session for copied total-only records', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
const copiedPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
totalOnlyUsageRecord('2026-05-26T12:00:00.000Z', 10),
totalOnlyUsageRecord('2026-05-26T12:01:00.000Z', 15)
].join('')
writeFileSync(originalPath, copiedPrefix, 'utf-8')
// Without cross-file ownership the fork's first copied total-only record
// re-counts the entire cumulative session (10) as a fresh delta.
writeFileSync(
forkPath,
`${copiedPrefix}${totalOnlyUsageRecord('2026-05-26T12:02:00.000Z', 22)}`
)
const result = await scanCodexUsageFiles([], [])
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0)
).toBe(3)
})
it('keeps fork dedupe stable when the original file is reused from cache', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'zzzz-fork.jsonl')
const copiedPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
].join('')
writeFileSync(originalPath, copiedPrefix, 'utf-8')
const first = await scanCodexUsageFiles([], [])
expect(
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(15)
expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
// A fork appears later while the original stays unchanged (cache reuse).
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(second.processedFiles.map((file) => file.ownedEventKeys.length)).toEqual([2, 1])
// Rescanning with the full cache stays stable.
const third = await scanCodexUsageFiles([], second.processedFiles)
expect(
third.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
})
it('reclaims copied token events when the owning original file is deleted', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
const copiedPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
].join('')
writeFileSync(originalPath, copiedPrefix, 'utf-8')
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
const first = await scanCodexUsageFiles([], [])
expect(
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
expect(first.processedFiles[0]?.hasDeferredClaims).toBe(false)
expect(first.processedFiles[1]?.ownedEventKeys).toHaveLength(1)
expect(first.processedFiles[1]?.hasDeferredClaims).toBe(true)
// Deleting the owner must reparse deferred forks so they can re-claim the
// copied prefix instead of permanently under-counting.
unlinkSync(originalPath)
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(22)
expect(second.processedFiles).toHaveLength(1)
expect(second.processedFiles[0]?.ownedEventKeys).toHaveLength(3)
})
it('keeps unrelated cached rollouts when a different owner file is deleted', async () => {
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
const unrelatedPath = join(sessionsDir, 'cccc-unrelated.jsonl')
const copiedPrefix = [
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T12:00:00.000Z', 10),
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
].join('')
writeFileSync(originalPath, copiedPrefix, 'utf-8')
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
writeFileSync(
unrelatedPath,
[
`${JSON.stringify({
type: 'session_meta',
payload: { id: 'session-9', cwd: join(fakeHomeDir, 'repo') }
})}\n`,
usageRecord('2026-05-26T13:00:00.000Z', 3)
].join('')
)
const first = await scanCodexUsageFiles([], [])
const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedPath)
expect(unrelatedBefore?.hasDeferredClaims).toBe(false)
expect(unrelatedBefore?.ownedEventKeys).toHaveLength(1)
unlinkSync(originalPath)
const second = await scanCodexUsageFiles([], first.processedFiles)
const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedPath)
expect(unrelatedAfter).toBe(unrelatedBefore)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
).toBe(25)
})
it('treats a leading total-only source suffix record as baseline', async () => {
const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
const runtimeBridgeMarkerDir = join(
+5
View File
@@ -99,6 +99,7 @@ describe('parseCodexUsageRecord', () => {
expect(first).toEqual({
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: expect.any(String),
cwd: '/workspace/repo/packages/app',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
@@ -196,6 +197,7 @@ describe('attributeCodexUsageEvent', () => {
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/app2/subdir',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
@@ -233,6 +235,7 @@ describe('attributeCodexUsageEvent', () => {
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/..fixtures/session',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
@@ -263,6 +266,7 @@ describe('attributeCodexUsageEvent', () => {
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: '/workspace/repo/../other/session',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
@@ -292,6 +296,7 @@ describe('attributeCodexUsageEvent', () => {
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
eventKey: 'event-1',
cwd: 'D:\\other\\repo',
model: 'gpt-5.2-codex',
hasInferredPricing: false,
+108 -14
View File
@@ -388,6 +388,28 @@ function resolveCodexUsageDelta(
return null
}
function buildCodexUsageEventKey(
timestamp: string,
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null
): string {
// Why: fork/resume copies token_count records byte-for-byte into a new
// rollout file, but session_meta.id is often rewritten to the new session.
// Key only on the raw record fields (timestamp + usage tuples) so the copy
// matches the original regardless of surrounding parse context / session id.
const tupleOf = (usage: CodexUsageRawUsage | null): string =>
usage
? [
usage.inputTokens,
usage.cachedInputTokens,
usage.outputTokens,
usage.reasoningOutputTokens,
usage.totalTokens
].join(',')
: ''
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
}
function extractString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
@@ -956,6 +978,7 @@ export function parseCodexUsageRecord(
return {
sessionId: context.sessionId,
timestamp: parsed.timestamp,
eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage),
cwd: context.currentCwd ?? context.sessionCwd,
model: resolvedModel,
hasInferredPricing,
@@ -970,7 +993,7 @@ export function parseCodexUsageRecord(
export async function parseCodexUsageFile(
filePath: string,
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[],
options: { skipInitialBytes?: number } = {}
options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {}
): Promise<CodexUsagePersistedFile> {
const processedFile = await getProcessedFileInfo(filePath)
const lines = createInterface({
@@ -992,11 +1015,21 @@ export async function parseCodexUsageFile(
totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0
}
const ownedEventKeys = new Set<string>()
let hasDeferredClaims = false
for await (const line of lines) {
const parsed = parseCodexUsageRecord(line, context)
if (!parsed) {
continue
}
// Why: fork/resume rollouts start with a copied prefix of the parent file.
// Events another file already owns are dropped here, but the record still
// advanced context.previousTotals above, so later deltas stay correct.
if (options.claimEventKey && !options.claimEventKey(parsed.eventKey)) {
hasDeferredClaims = true
continue
}
ownedEventKeys.add(parsed.eventKey)
const attributed = await attributeCodexUsageEvent(parsed, worktrees)
if (attributed) {
events.push(attributed)
@@ -1005,7 +1038,9 @@ export async function parseCodexUsageFile(
return {
...processedFile,
...aggregateCodexUsage(events)
...aggregateCodexUsage(events),
ownedEventKeys: [...ownedEventKeys],
hasDeferredClaims
}
}
@@ -1019,31 +1054,77 @@ export async function scanCodexUsageFiles(
}> {
const files = await listCodexSessionFiles()
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const processedFiles: CodexUsagePersistedFile[] = []
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files)
const sessionsById = new Map<string, CodexUsageSession>()
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
const currentPaths = new Set(files)
// Why: when a rollout that owned event keys is deleted, remaining forks still
// contain those records but their caches record them as unowned. Only files
// that previously deferred claims can reclaim, so invalidate those — not the
// entire rollout corpus.
const lostOwnerPath = previousProcessedFiles.some(
(file) =>
!currentPaths.has(file.path) &&
Array.isArray(file.ownedEventKeys) &&
file.ownedEventKeys.length > 0
)
const reusedByPath = new Map<string, CodexUsagePersistedFile>()
const pathsToParse: string[] = []
for (const [index, filePath] of files.entries()) {
const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0
const fileInfo = await getProcessedFileInfo(filePath)
const previous = previousByPath.get(filePath)
// When an owner disappears, only deferred-claim files need reparse.
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
const canReuse =
!mustReclaimDeferred &&
legacySourceSkipBytes === 0 &&
previous &&
previous.mtimeMs === fileInfo.mtimeMs &&
previous.size === fileInfo.size
previous.size === fileInfo.size &&
Array.isArray(previous.ownedEventKeys) &&
typeof previous.hasDeferredClaims === 'boolean'
if (canReuse) {
reusedByPath.set(filePath, previous)
} else {
pathsToParse.push(filePath)
}
if ((index + 1) % YIELD_EVERY_FILES === 0) {
await yieldToEventLoop()
}
}
const processed = canReuse
? previous
: await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
skipInitialBytes: legacySourceSkipBytes
})
// Why: resuming or forking a Codex session copies the parent rollout's
// token_count records into a new file, so per-file parsing re-counts the
// whole copied history once per descendant (#8006). Cross-file ownership
// counts each record for exactly one file; cached files keep the claims
// they persisted, and new files claim in sorted-path order so rescans stay
// deterministic.
const eventOwnerByKey = new Map<string, string>()
for (const [filePath, previous] of reusedByPath) {
for (const eventKey of previous.ownedEventKeys) {
// First cached claim wins so conflicting projections stay deterministic.
if (!eventOwnerByKey.has(eventKey)) {
eventOwnerByKey.set(eventKey, filePath)
}
}
}
processedFiles.push(processed)
mergeSessions(sessionsById, processed.sessions)
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
const parsedByPath = new Map<string, CodexUsagePersistedFile>()
for (const [index, filePath] of pathsToParse.entries()) {
const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0,
claimEventKey: (eventKey) => {
const owner = eventOwnerByKey.get(eventKey)
if (owner !== undefined && owner !== filePath) {
return false
}
eventOwnerByKey.set(eventKey, filePath)
return true
}
})
parsedByPath.set(filePath, processed)
// Why: Codex session history can grow large, and scans run on the Electron
// main process. Yield regularly so opening Settings does not stall while
@@ -1053,6 +1134,19 @@ export async function scanCodexUsageFiles(
}
}
const processedFiles: CodexUsagePersistedFile[] = []
const sessionsById = new Map<string, CodexUsageSession>()
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
for (const filePath of files) {
const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath)
if (!processed) {
continue
}
processedFiles.push(processed)
mergeSessions(sessionsById, processed.sessions)
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
return {
processedFiles,
sessions: finalizeSessions(sessionsById),
+3 -3
View File
@@ -111,7 +111,7 @@ describe('CodexUsageStore', () => {
it('persists a successful refresh with one compact disk write', async () => {
const store = createStoreWithState({
schemaVersion: 3,
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
@@ -138,7 +138,7 @@ describe('CodexUsageStore', () => {
const pendingScan = createDeferred<ScanResult>()
vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise)
const store = createStoreWithState({
schemaVersion: 3,
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
@@ -768,7 +768,7 @@ describe('CodexUsageStore', () => {
} as unknown as CodexUsagePersistedState)
expect(normalized).toEqual({
schemaVersion: 3,
schemaVersion: 5,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
+4 -1
View File
@@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
const SCHEMA_VERSION = 3
// Why: v5 keys Codex ownership on raw token_count identity without session id
// so forks that rewrite session_meta still match. Older caches used session-
// scoped keys and can double-count after fork/resume (#8006).
const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
+12
View File
@@ -86,6 +86,14 @@ export type CodexUsageDailyAggregate = {
export type CodexUsagePersistedFile = CodexUsageProcessedFile & {
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
/** Event keys this file counted. Resumed/forked rollouts copy earlier
* token_count records into new files; ownership keeps each record counted
* by exactly one cached file across incremental scans. */
ownedEventKeys: string[]
/** True when this file saw events already claimed by another file. When that
* owner disappears, only deferred files need reparse to reclaim — not the
* entire rollout corpus. */
hasDeferredClaims: boolean
}
export type CodexUsagePersistedState = {
@@ -105,6 +113,10 @@ export type CodexUsagePersistedState = {
export type CodexUsageParsedEvent = {
sessionId: string
timestamp: string
/** Raw-record identity (timestamp + token tuples) used to dedupe the same
* token_count record copied across fork/resume rollout files. Session id is
* omitted because forks rewrite session_meta while copying the record. */
eventKey: string
model: string | null
cwd: string | null
hasInferredPricing: boolean
@@ -25,6 +25,7 @@ vi.mock('node:os', async () => {
import {
prepareSystemConfigForFreshRuntimeMirror,
resolveCodexConfigMirrorSourceDirectory,
syncSystemConfigIntoManagedCodexHome
} from './codex-config-mirror'
@@ -402,6 +403,20 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
})
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
it('uses the Linux-side directory for WSL UNC source homes', () => {
const sourceDir = resolveCodexConfigMirrorSourceDirectory(
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
)
expect(sourceDir).toBe('/home/alice/.codex')
expect(
prepareSystemConfigForFreshRuntimeMirror(
'model_instructions_file = "instructions.md"\n',
sourceDir
)
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
})
it('rewrites relative paths against a Linux-side home and strips hook trust', () => {
const prepared = prepareSystemConfigForFreshRuntimeMirror(
[
+29 -21
View File
@@ -3,9 +3,11 @@ import { dirname, join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
promoteCodexRuntimeSettingsToSystem,
snapshotCodexRuntimeSettingsBaseline
snapshotCodexRuntimeSettingsBaseline,
type CodexSettingsPromotionHomes
} from './config-settings-promotion'
import {
createTomlLineScanState,
@@ -14,33 +16,37 @@ import {
updateTomlLineScanState
} from './config-toml-line-scan'
function getRuntimeCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
function getSystemCodexConfigTomlPath(): string {
return join(getSystemCodexHomePath(), 'config.toml')
}
export function syncSystemConfigIntoManagedCodexHome(): void {
export function syncSystemConfigIntoManagedCodexHome(
homes: CodexSettingsPromotionHomes = {
runtimeHomePath: getOrcaManagedCodexHomePath(),
systemHomePath: getSystemCodexHomePath()
}
): void {
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
// user made inside Orca-launched Codex (/model, /approvals) must be written
// back to ~/.codex first or this very pass silently reverts them.
promoteCodexRuntimeSettingsToSystem()
if (!promoteCodexRuntimeSettingsToSystem(homes)) {
// Why: mirroring after a failed write-back would erase the runtime change;
// leave both runtime and its old baseline intact so the next launch retries.
return
}
try {
syncSystemConfigIntoManagedCodexHomeUnsafe()
syncSystemConfigIntoManagedCodexHomeUnsafe(homes)
} catch (error) {
console.warn('[codex-config] Failed to mirror system Codex config:', error)
return
}
// Why: the baseline advances only after a successful mirror; recording an
// unpromoted runtime change as Orca-written would strand it forever.
snapshotCodexRuntimeSettingsBaseline()
snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath)
}
function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
const systemConfigPath = getSystemCodexConfigTomlPath()
const runtimeConfigPath = getRuntimeCodexConfigTomlPath()
function syncSystemConfigIntoManagedCodexHomeUnsafe({
runtimeHomePath,
systemHomePath
}: CodexSettingsPromotionHomes): void {
const systemConfigPath = join(systemHomePath, 'config.toml')
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
const systemConfigExists = existsSync(systemConfigPath)
const runtimeConfigExists = existsSync(runtimeConfigPath)
if (!systemConfigExists && !runtimeConfigExists) {
@@ -48,18 +54,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
}
const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath)
if (!runtimeConfigExists) {
writeFileAtomically(
runtimeConfigPath,
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath))
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
)
return
}
const systemConfig = prepareSystemConfigForRuntimeMirror(
rawSystemConfig,
dirname(systemConfigPath)
)
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
if (mergedConfig !== runtimeConfig) {
@@ -67,6 +71,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
}
}
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml'))
}
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
return rewriteRelativePathConfigValues(
normalizeDeprecatedCodexHookFeatureFlag(config),
@@ -1,11 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import type * as CodexFsUtils from '../codex-accounts/fs-utils'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>(),
promotionTestState: { failAtomicWrite: false }
}))
vi.mock('node:os', async (importOriginal) => {
@@ -16,6 +30,19 @@ vi.mock('node:os', async (importOriginal) => {
}
})
vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
const actual = await importOriginal<typeof CodexFsUtils>()
return {
...actual,
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
if (promotionTestState.failAtomicWrite) {
throw new Error('injected atomic write failure')
}
return actual.writeFileAtomically(...args)
}
}
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
@@ -29,6 +56,7 @@ beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
promotionTestState.failAtomicWrite = false
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
// intercept, these tests would rewrite the developer's real Codex config.
if (homedir() !== tmpHome) {
@@ -188,7 +216,9 @@ describe('codex settings write-back promotion', () => {
})
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
// Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the
// config nor its directory, and promotion must create both.
expect(existsSync(join(tmpHome, '.codex'))).toBe(false)
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
@@ -266,6 +296,110 @@ describe('codex settings write-back promotion', () => {
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it.skipIf(process.platform === 'win32')(
'preserves a restrictive mode on the existing ~/.codex/config.toml',
() => {
writeSystemConfig('model = "gpt-5"\n')
chmodSync(systemConfigPath(), 0o600)
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
}
)
it.skipIf(process.platform === 'win32')(
'creates a new ~/.codex/config.toml with owner-only permissions',
() => {
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
// The created ~/.codex itself is owner-only — it will also hold
// auth.json once the user signs in.
expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700)
}
)
it.skipIf(process.platform === 'win32')(
'writes through a symlinked config.toml without replacing the link',
() => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
const realConfigPath = join(tmpHome, 'dotfiles-config.toml')
writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8')
symlinkSync(realConfigPath, systemConfigPath())
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true)
expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n')
}
)
it.skipIf(process.platform === 'win32')(
'preserves a dangling config.toml symlink and creates its target',
() => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml')
symlinkSync(realConfigPath, systemConfigPath())
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true)
expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n')
}
)
it('inserts a missing key into a CRLF config with CRLF endings', () => {
writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"never"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n'
)
})
it('does not rewrite an unchanged settings baseline', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
const past = new Date(Date.now() - 120_000)
utimesSync(baselinePath(), past, past)
syncSystemConfigIntoManagedCodexHome()
expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000)
})
it('keeps the old baseline and retries after a transient promotion failure', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8')
simulateCodexSettingWrite('model', '"o4"')
promotionTestState.failAtomicWrite = true
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(readRuntimeConfig()).toBe('model = "o4"\n')
expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure)
promotionTestState.failAtomicWrite = false
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
@@ -293,4 +427,16 @@ describe('upsertTopLevelSettingsInContent', () => {
)
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
it('inserts with CRLF endings into CRLF content', () => {
expect(
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n')
})
it('appends with CRLF to a CRLF preamble-only file', () => {
expect(
upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']]))
).toBe('approval_policy = "never"\r\nmodel = "x"\r\n')
})
})
+119 -20
View File
@@ -1,6 +1,16 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
realpathSync,
statSync,
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import {
createTomlLineScanState,
@@ -103,9 +113,10 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
* from "value Codex wrote for the user". Call after a successful mirror only —
* advancing the baseline past an unpromoted change would strand it forever.
*/
export function snapshotCodexRuntimeSettingsBaseline(): void {
export function snapshotCodexRuntimeSettingsBaseline(
runtimeHomePath = getOrcaManagedCodexHomePath()
): void {
try {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: a missing runtime config still records an empty baseline — when
// Codex later creates the file for a user with no ~/.codex/config.toml,
@@ -117,7 +128,14 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
}
}
const file: SettingsBaselineFile = { version: 1, settings }
writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
const serialized = `${JSON.stringify(file, null, 2)}\n`
// Why: launch preparation can run repeatedly; skip byte-identical rewrites
// so an unchanged pass does no disk writes.
if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) {
return
}
writeFileSync(baselinePath, serialized, {
encoding: 'utf-8',
mode: 0o600
})
@@ -126,25 +144,39 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
}
}
export type CodexSettingsPromotionHomes = {
runtimeHomePath: string
systemHomePath: string
}
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
return {
runtimeHomePath: getOrcaManagedCodexHomePath(),
systemHomePath: getSystemCodexHomePath()
}
}
/**
* Promotes setting changes the user made inside Orca-launched Codex (written
* by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs
* before the config mirror so the promoted values survive the same mirror
* pass instead of reverting.
* pass instead of reverting. WSL callers pass explicit per-distro homes; the
* default is the host runtime home and host ~/.codex.
*/
export function promoteCodexRuntimeSettingsToSystem(): void {
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
try {
promoteCodexRuntimeSettingsToSystemUnsafe()
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
return true
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed runtime file
// must not block the config mirror or the Codex launch itself.
// Why: promotion is best-effort launch prep; callers preserve the runtime
// for retry, while a malformed file must not block Codex launch itself.
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
return false
}
}
function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const systemHomePath = getSystemCodexHomePath()
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
const { runtimeHomePath, systemHomePath } = homes
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
const systemTomlPath = join(systemHomePath, 'config.toml')
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
@@ -188,8 +220,71 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
if (updates.size === 0) {
return
}
const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : ''
writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates))
// Why: a genuinely fresh host has no ~/.codex yet; without the directory
// the atomic write ENOENTs and the following mirror wipes the setting.
// Owner-only: the directory holds auth.json and the full user config.
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
const writeTarget = resolvePromotionWriteTarget(systemTomlPath)
// Why: a dangling dotfile-manager symlink can point into a directory tree
// that has not been materialized yet; preserve the link and create its real
// parent so the atomic temp file can be written beside the target.
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: symlink metadata through the \\wsl$ 9P provider is not reliable
// enough for realpath/lstat detection, and an atomic rename would replace
// a WSL-side dotfile symlink with a plain file. Writing through the
// existing file preserves the Linux-side inode (and its mode).
writeFileSync(writeTarget.path, nextContent, 'utf-8')
return
}
writeFileAtomically(writeTarget.path, nextContent, {
mode: writeTarget.mode
})
}
// Why: promotion rewrites the user's real config.toml. Follow an existing
// symlink (dotfile managers) instead of replacing the link with a plain file,
// and carry the real file's mode forward — an atomic write without a mode
// would widen a user-restricted 0600 config to the process umask default.
// A new or unreadable target is created owner-only.
function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } {
try {
const realPath = realpathSync(systemTomlPath)
return { path: realPath, mode: statSync(realPath).mode & 0o777 }
} catch {
// Continue below: realpath also fails for a valid dangling dotfile link.
}
try {
if (lstatSync(systemTomlPath).isSymbolicLink()) {
const targetPath = resolveDanglingSymlinkTarget(systemTomlPath)
return { path: targetPath, mode: 0o600 }
}
} catch {
// Missing non-link targets are created owner-only at the requested path.
}
return { path: systemTomlPath, mode: 0o600 }
}
function resolveDanglingSymlinkTarget(linkPath: string): string {
let currentPath = linkPath
const visited = new Set<string>()
while (!visited.has(currentPath)) {
visited.add(currentPath)
try {
if (!lstatSync(currentPath).isSymbolicLink()) {
return currentPath
}
currentPath = resolve(dirname(currentPath), readlinkSync(currentPath))
} catch {
return currentPath
}
}
// Why: replacing any link in a cycle would destroy dotfile-manager state;
// abort promotion and leave the runtime/baseline intact for manual repair.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
export function upsertTopLevelSettingsInContent(
@@ -215,16 +310,17 @@ export function upsertTopLevelSettingsInContent(
state = updateTomlLineScanState(state, line)
}
// Why: CRLF configs keep a trailing \r after the split; new lines must use
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
// Why: CRLF configs keep a trailing \r after the split; preserve it so
// the rewritten line matches the file's existing endings.
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(rendered)
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
@@ -233,10 +329,13 @@ export function upsertTopLevelSettingsInContent(
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push('')
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
return result.endsWith('\n') || result.length === 0 ? result : `${result}\n`
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
@@ -213,6 +213,7 @@ describe('Codex WSL runtime hook install', () => {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
WSL_DISTRO_NAME: 'Ubuntu',
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: '43210',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_PANE_KEY: 'pane-1',
@@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s
const TEST_UDID = '11111111-2222-3333-4444-555555555555'
let isolatedStateDirSeq = 0
// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state
// file from an actual simulator session would leak extra events into tests
// that only exercise PTY ingestion. Point at a dir that never exists.
function createIsolatedWatcher(): ServeSimStateWatcher {
isolatedStateDirSeq += 1
return new ServeSimStateWatcher({
stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`)
})
}
async function waitForEvent(
events: ServeSimStateDetectedEvent[],
predicate: (event: ServeSimStateDetectedEvent) => boolean
@@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => {
})
it('suppresses Orca-managed sessions only while they are marked managed', async () => {
const watcher = new ServeSimStateWatcher()
const watcher = createIsolatedWatcher()
const events: ServeSimStateDetectedEvent[] = []
const payload = JSON.stringify({
device: TEST_UDID,
@@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => {
})
it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => {
const watcher = new ServeSimStateWatcher()
const watcher = createIsolatedWatcher()
const events: ServeSimStateDetectedEvent[] = []
const payload = JSON.stringify({
device: TEST_UDID,
@@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => {
})
it('dedupes one helper without hiding a later helper for the same simulator', () => {
const watcher = new ServeSimStateWatcher()
const watcher = createIsolatedWatcher()
const events: ServeSimStateDetectedEvent[] = []
const firstPayload = JSON.stringify({
device: TEST_UDID,
+60 -11
View File
@@ -848,6 +848,43 @@ async function fetchIssueWorkItem(
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON
// `gh pr view` path fails, still pull review fields from gh so mobile/desktop
// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty.
const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
async function fetchPullRequestReviewFields(
number: number,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
try {
const args = ownerRepo
? [
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_REVIEW_JSON_FIELDS
]
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
const { stdout } = await ghExecFileAsync(args, ghOptions)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
...(item.reviewRequests !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {})
}
} catch {
return {}
}
}
async function fetchPullRequestWorkItem(
repoPath: string,
ownerRepo: OwnerRepo | null,
@@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem(
)
const item = JSON.parse(stdout) as Record<string, unknown>
const mapped = mapPullRequestWorkItem(item, ownerRepo)
// Why: merge-metadata GraphQL is best-effort. A failure here must not fall
// through to the REST path below — that path drops latestReviews and blanks
// the mobile/desktop reviewer list for bots that only left a review.
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
return {
...mapped,
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
try {
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
return {
...mapped,
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
} catch {
return mapped
}
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>, ownerRepo)
const mapped = mapPullRequestWorkItem(
JSON.parse(stdout) as Record<string, unknown>,
ownerRepo
)
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
return { ...mapped, ...reviewFields }
}
}
+14
View File
@@ -51,6 +51,7 @@ import {
rebuildAppMenu
} from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import {
configureElectronNetworkCompatibility,
configureDevUserDataPath,
@@ -2210,6 +2211,11 @@ app.whenReady().then(async () => {
})
app.on('before-quit', () => {
if (isQuittingForUpdate()) {
recordUpdaterLifecycle('before_quit_allowed', undefined, {
message: 'before-quit allowed for update install'
})
}
isQuitting = true
unsubscribeSystemResumeBroadcast?.()
unsubscribeSystemResumeBroadcast = null
@@ -2231,6 +2237,14 @@ app.on('before-quit', () => {
// async work and let Electron exit.
let daemonDisconnectDone = false
app.on('will-quit', (e) => {
const updateQuitInProgress = isQuittingForUpdate()
if (updateQuitInProgress) {
recordUpdaterLifecycle(
'will_quit_cleanup_started',
{ daemonTeardown: 'disconnect' },
{ message: 'will-quit cleanup for update install; daemonTeardown=disconnect' }
)
}
// Why: before-quit can still be aborted by renderer beforeunload; wait until
// the committed quit path before removing the Windows notification icon.
destroySystemTray()
+37 -1
View File
@@ -97,7 +97,43 @@ describe('registerLinearHandlers', () => {
workspaceId: 'workspace-1'
})
expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1')
expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1', {
attributeFilter: undefined
})
})
it('parses attribute filters and rejects malformed payloads before listIssues', async () => {
listIssuesMock.mockResolvedValue({ items: [], hasMore: false })
registerLinearHandlers()
await handlers['linear:listIssues'](null, {
filter: 'all',
limit: 20,
workspaceId: 'workspace-1',
attributeFilter: {
stateIds: ['state-1'],
priorities: [1],
assignee: { kind: 'unassigned' },
labelIds: []
}
})
expect(listIssuesMock).toHaveBeenCalledWith('all', 20, 'workspace-1', {
attributeFilter: {
stateIds: ['state-1'],
priorities: [1],
assignee: { kind: 'unassigned' },
labelIds: []
}
})
listIssuesMock.mockClear()
await expect(
handlers['linear:listIssues'](null, {
filter: 'all',
attributeFilter: { stateIds: [] }
})
).rejects.toThrow(/required/)
expect(listIssuesMock).not.toHaveBeenCalled()
})
it('forwards expanded Linear project issue limits through local IPC', async () => {
+16 -2
View File
@@ -25,6 +25,7 @@ import {
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
import type { LinearListFilter } from '../linear/issues'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear-issue-attribute-filter'
import type {
LinearCustomViewModel,
LinearIssueUpdate,
@@ -131,13 +132,26 @@ export function registerLinearHandlers(): void {
'linear:listIssues',
async (
_event,
args?: { filter?: LinearListFilter; limit?: number; workspaceId?: LinearWorkspaceSelection }
args?: {
filter?: LinearListFilter
limit?: number
workspaceId?: LinearWorkspaceSelection
attributeFilter?: unknown
}
) => {
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
? (args!.filter as LinearListFilter)
: undefined
const limit = clampLinearIssueListLimit(args?.limit)
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId))
// Why: reject malformed filters at the trust boundary instead of
// normalizing them to empty (which would silently broaden results).
const attributeFilter =
args && 'attributeFilter' in args && args.attributeFilter !== undefined
? optionalParsedLinearIssueAttributeFilter(args.attributeFilter)
: undefined
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId), {
attributeFilter
})
}
)
+1 -1
View File
@@ -3928,7 +3928,7 @@ export function registerHeadlessPtyRuntime(
}
/**
* Kill all PTY processes. Call on app quit.
* Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect.
*/
export function killAllPty(): void {
if (localProvider instanceof LocalPtyProvider) {
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { buildLinearListIssueFilter } from './issue-list-filter'
describe('buildLinearListIssueFilter', () => {
it('returns undefined for all preset without attributes or team', () => {
expect(buildLinearListIssueFilter({ filter: 'all' })).toBeUndefined()
})
it('merges status ids, priority, assignee, and labels for the all preset', () => {
expect(
buildLinearListIssueFilter({
filter: 'all',
attributeFilter: {
stateIds: ['state-1', 'state-2'],
priorities: [0, 1],
assignee: { kind: 'user', id: 'user-1' },
labelIds: ['label-1', 'label-2']
}
})
).toEqual({
state: { id: { in: ['state-1', 'state-2'] } },
priority: { in: [0, 1] },
assignee: { id: { eq: 'user-1' } },
labels: { some: { id: { in: ['label-1', 'label-2'] } } }
})
})
it('keeps preset state.type and attribute state.id together', () => {
expect(
buildLinearListIssueFilter({
filter: 'assigned',
attributeFilter: {
stateIds: ['state-open'],
priorities: [],
assignee: null,
labelIds: []
}
})
).toEqual({
state: {
type: { nin: ['completed', 'canceled'] },
id: { in: ['state-open'] }
}
})
})
it('shapes unassigned and optional team filters', () => {
expect(
buildLinearListIssueFilter({
filter: 'all',
teamId: 'team-1',
attributeFilter: {
stateIds: [],
priorities: [],
assignee: { kind: 'unassigned' },
labelIds: []
}
})
).toEqual({
team: { id: { eq: 'team-1' } },
assignee: { null: true }
})
})
it('omits empty attribute filters so variables match the unfiltered path', () => {
expect(
buildLinearListIssueFilter({
filter: 'completed',
attributeFilter: {
stateIds: [],
priorities: [],
assignee: null,
labelIds: []
}
})
).toEqual({
state: { type: { in: ['completed', 'canceled'] } }
})
})
})
+117
View File
@@ -0,0 +1,117 @@
import {
isEmptyLinearIssueAttributeFilter,
type LinearIssueAttributeFilter
} from '../../shared/linear-issue-attribute-filter'
import type { LinearListFilter } from './issues'
// Why: attribute facets must merge into Linear's IssueFilter before the first-N
// cursor walk so hasMore reflects the filtered set, not the unfiltered window.
// @linear/sdk does not export IssueFilter as a public type, so we keep a
// structural subset that matches the operators this list path actually sends.
export type LinearGraphQLIssueFilter = {
state?: {
type?: { nin?: string[]; in?: string[] }
id?: { in?: string[] }
}
team?: { id?: { eq?: string } }
priority?: { in?: number[] }
assignee?: { null?: boolean; id?: { eq?: string } }
labels?: { some?: { id?: { in?: string[] } } }
}
const ACTIVE_STATE_FILTER: LinearGraphQLIssueFilter = {
state: { type: { nin: ['completed', 'canceled'] } }
}
const COMPLETED_STATE_FILTER: LinearGraphQLIssueFilter = {
state: { type: { in: ['completed', 'canceled'] } }
}
function listFilterForState(filter: LinearListFilter): LinearGraphQLIssueFilter | undefined {
if (filter === 'assigned' || filter === 'created' || filter === 'open') {
return ACTIVE_STATE_FILTER
}
if (filter === 'completed') {
return COMPLETED_STATE_FILTER
}
return undefined
}
function attributeFacetFilter(
attributeFilter: LinearIssueAttributeFilter | null | undefined
): LinearGraphQLIssueFilter | undefined {
if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) {
return undefined
}
const next: LinearGraphQLIssueFilter = {}
if (attributeFilter.stateIds.length > 0) {
next.state = { id: { in: attributeFilter.stateIds } }
}
if (attributeFilter.priorities.length > 0) {
next.priority = { in: attributeFilter.priorities }
}
if (attributeFilter.assignee?.kind === 'unassigned') {
next.assignee = { null: true }
} else if (attributeFilter.assignee?.kind === 'user') {
next.assignee = { id: { eq: attributeFilter.assignee.id } }
}
if (attributeFilter.labelIds.length > 0) {
// Why: collection `some` is any-of; a direct labels.id comparator would
// not express multi-label membership the same way.
next.labels = { some: { id: { in: attributeFilter.labelIds } } }
}
return Object.keys(next).length > 0 ? next : undefined
}
function mergeIssueFilters(
...parts: (LinearGraphQLIssueFilter | undefined)[]
): LinearGraphQLIssueFilter | undefined {
const defined = parts.filter((part): part is LinearGraphQLIssueFilter => part !== undefined)
if (defined.length === 0) {
return undefined
}
if (defined.length === 1) {
return defined[0]
}
const merged: LinearGraphQLIssueFilter = {}
for (const part of defined) {
if (part.state) {
// Why: preset state.type and attribute state.id must coexist; a top-level
// spread would overwrite one nested constraint.
merged.state = { ...merged.state, ...part.state }
}
if (part.team) {
merged.team = part.team
}
if (part.priority) {
merged.priority = part.priority
}
if (part.assignee) {
merged.assignee = part.assignee
}
if (part.labels) {
merged.labels = part.labels
}
}
return merged
}
export function buildLinearListIssueFilter(options: {
filter: LinearListFilter
teamId?: string
attributeFilter?: LinearIssueAttributeFilter | null
}): LinearGraphQLIssueFilter | undefined {
const stateFilter = listFilterForState(options.filter)
const teamFilter: LinearGraphQLIssueFilter | undefined = options.teamId
? { team: { id: { eq: options.teamId } } }
: undefined
const attributes = attributeFacetFilter(options.attributeFilter)
return mergeIssueFilters(stateFilter, teamFilter, attributes)
}
+45 -1
View File
@@ -188,7 +188,9 @@ describe('Linear issue queries', () => {
})
const { listIssues } = await import('./issues')
await expect(listIssues('open', 10, 'workspace-1', 'team-1')).resolves.toMatchObject({
await expect(
listIssues('open', 10, 'workspace-1', { teamId: 'team-1' })
).resolves.toMatchObject({
items: [{ id: 'LIN-1' }],
hasMore: false
})
@@ -202,6 +204,48 @@ describe('Linear issue queries', () => {
})
})
it('passes attribute facets into Linear GraphQL variables before pagination', async () => {
rawRequest.mockResolvedValueOnce({
data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: false } } }
})
const { listIssues } = await import('./issues')
await expect(
listIssues('all', 10, 'workspace-1', {
attributeFilter: {
stateIds: ['state-1'],
priorities: [0, 1],
assignee: { kind: 'unassigned' },
labelIds: ['label-1']
}
})
).resolves.toMatchObject({ items: [{ id: 'LIN-1' }] })
expect(rawRequest.mock.calls[0][1]).toMatchObject({
filter: {
state: { id: { in: ['state-1'] } },
priority: { in: [0, 1] },
assignee: { null: true },
labels: { some: { id: { in: ['label-1'] } } }
}
})
})
it('rejects non-empty attribute filters when workspace scope is all', async () => {
const { listIssues } = await import('./issues')
await expect(
listIssues('all', 10, 'all', {
attributeFilter: {
stateIds: ['state-1'],
priorities: [],
assignee: null,
labelIds: []
}
})
).rejects.toThrow(/concrete workspace/i)
expect(getClients).not.toHaveBeenCalled()
})
it('keeps single-workspace search results in Linear relevance order', async () => {
rawRequest.mockResolvedValueOnce({
data: {
+38 -34
View File
@@ -14,6 +14,10 @@ import {
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
clampLinearIssueListLimit
} from '../../shared/linear-issue-read-limits'
import {
isEmptyLinearIssueAttributeFilter,
type LinearIssueAttributeFilter
} from '../../shared/linear-issue-attribute-filter'
import {
acquire,
release,
@@ -22,8 +26,14 @@ import {
clearToken,
type LinearClientForWorkspace
} from './client'
import { buildLinearListIssueFilter } from './issue-list-filter'
import { mapLinearIssue } from './mappers'
export type LinearIssueListOptions = {
teamId?: string
attributeFilter?: LinearIssueAttributeFilter | null
}
type LinearIssueNode = {
id: string
identifier: string
@@ -457,11 +467,17 @@ function getOldestIssueTime(issues: LinearIssue[]): number {
function getListIssueConnectionLoader(
entry: LinearClientForWorkspace,
filter: LinearListFilter,
teamId?: string
options?: LinearIssueListOptions
): LinearIssueConnectionLoader {
const orderBy = 'updatedAt'
const variables = { orderBy }
const filterInput = listIssueFilter(filter, teamId)
// Why: apply attribute + team filters in GraphQL variables before the first-N
// cursor walk so pagination hasMore matches the filtered set.
const filterInput = buildLinearListIssueFilter({
filter,
teamId: options?.teamId,
attributeFilter: options?.attributeFilter
})
if (filter === 'assigned') {
return async (page) => {
@@ -833,31 +849,6 @@ export async function searchIssues(
export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' | 'open'
const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } }
const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } }
function listFilterForState(filter: LinearListFilter): Record<string, unknown> | undefined {
if (filter === 'assigned' || filter === 'created' || filter === 'open') {
return ACTIVE_STATE_FILTER
}
if (filter === 'completed') {
return COMPLETED_STATE_FILTER
}
return undefined
}
function listIssueFilter(
filter: LinearListFilter,
teamId?: string
): Record<string, unknown> | undefined {
const stateFilter = listFilterForState(filter)
const teamFilter = teamId ? { team: { id: { eq: teamId } } } : undefined
if (stateFilter && teamFilter) {
return { ...stateFilter, ...teamFilter }
}
return stateFilter ?? teamFilter
}
type LinearIssuePageResult = {
items: LinearIssue[]
hasMore: boolean
@@ -904,14 +895,14 @@ async function readListIssuesForWorkspace(
filter: LinearListFilter,
limit: number,
workspaceId: LinearWorkspaceSelection | null | undefined,
teamId?: string
options?: LinearIssueListOptions
): Promise<LinearCollectionResult<LinearIssue>> {
await acquire()
try {
return await readIssueConnectionPages(
entry,
limit,
getListIssueConnectionLoader(entry, filter, teamId)
getListIssueConnectionLoader(entry, filter, options)
)
} catch (error) {
if (isAuthError(error)) {
@@ -1016,11 +1007,11 @@ async function readListIssuesAcrossWorkspaces(
filter: LinearListFilter,
limit: number,
workspaceId: LinearWorkspaceSelection | null | undefined,
teamId?: string
options?: LinearIssueListOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({
entry,
loadConnection: getListIssueConnectionLoader(entry, filter, teamId),
loadConnection: getListIssueConnectionLoader(entry, filter, options),
items: [],
hasMore: false,
canPage: false
@@ -1063,19 +1054,32 @@ export async function listIssues(
filter: LinearListFilter = 'assigned',
limit = 20,
workspaceId?: LinearWorkspaceSelection | null,
teamId?: string
options?: LinearIssueListOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const effectiveLimit = clampLinearIssueListLimit(limit)
const attributeFilter = options?.attributeFilter
// Why: workspace-specific state/member/label ids cannot fan out safely across
// "all" workspaces; reject before creating clients so non-UI callers cannot
// get a misleading partial subset.
if (
attributeFilter &&
!isEmptyLinearIssueAttributeFilter(attributeFilter) &&
workspaceId === 'all'
) {
throw new Error(
'Linear attribute filters require a concrete workspace; "all" workspaces is not supported.'
)
}
const entries = getClients(workspaceId)
if (entries.length === 0) {
return { items: [] }
}
if (entries.length === 1) {
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, teamId)
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, options)
}
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, teamId)
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, options)
}
export async function createIssue(
+210 -3
View File
@@ -1,12 +1,13 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import {
attributeOpenCodeUsageEvent,
parseOpenCodeUsageDatabase,
parseOpenCodeUsageRow
parseOpenCodeUsageRow,
scanOpenCodeUsageDatabases
} from './scanner'
const WORKTREE = '/workspace/repo'
@@ -32,6 +33,50 @@ function worktrees() {
]
}
function createSessionTotalsSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
model TEXT,
cost REAL,
tokens_input INTEGER,
tokens_output INTEGER,
tokens_reasoning INTEGER,
tokens_cache_read INTEGER,
time_created INTEGER,
time_updated INTEGER
);
`)
}
function insertSessionTotalsRow(
db: Database.Database,
sessionId: string,
inputTokens: number
): void {
db.prepare(
`INSERT INTO session (
id, directory, title, model, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
time_created, time_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sessionId,
`${WORKTREE}/packages/app`,
'Session',
JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }),
0.01,
inputTokens,
100,
0,
0,
1_777_777_700_000,
1_777_777_800_000
)
}
function usageEvent(cwd: string) {
return {
sessionId: 'session-1',
@@ -259,6 +304,17 @@ describe('parseOpenCodeUsageDatabase', () => {
})
})
it('reports the session ids the database counted', async () => {
const { db, path } = createTempDb()
createSessionTotalsSchema(db)
insertSessionTotalsRow(db, 'session-1', 1000)
db.close()
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
expect(parsed.ownedSessionIds).toEqual(['session-1'])
})
it('prefers session_message rows over legacy message rows to avoid double counting', async () => {
const { db, path } = createTempDb()
db.exec(`
@@ -324,3 +380,154 @@ describe('parseOpenCodeUsageDatabase', () => {
expect(parsed.sessions[0]?.eventCount).toBe(1)
})
})
describe('scanOpenCodeUsageDatabases', () => {
let dataRoot: string
let openCodeDir: string
let previousXdgDataHome: string | undefined
let previousOpenCodeDb: string | undefined
beforeEach(() => {
dataRoot = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-scan-'))
openCodeDir = join(dataRoot, 'opencode')
mkdirSync(openCodeDir, { recursive: true })
previousXdgDataHome = process.env.XDG_DATA_HOME
previousOpenCodeDb = process.env.OPENCODE_DB
process.env.XDG_DATA_HOME = dataRoot
delete process.env.OPENCODE_DB
})
afterEach(() => {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome
}
if (previousOpenCodeDb === undefined) {
delete process.env.OPENCODE_DB
} else {
process.env.OPENCODE_DB = previousOpenCodeDb
}
rmSync(dataRoot, { recursive: true, force: true })
})
function writeSessionTotalsDb(fileName: string, rows: [string, number][]): string {
const path = join(openCodeDir, fileName)
const db = new Database(path)
createSessionTotalsSchema(db)
for (const [sessionId, inputTokens] of rows) {
insertSessionTotalsRow(db, sessionId, inputTokens)
}
db.close()
return path
}
it('counts a session duplicated into a stale backup database exactly once', async () => {
// The backup holds a stale snapshot of session-1; the canonical db has
// grown since. The canonical totals must win and be counted once.
writeSessionTotalsDb('opencode.db', [
['session-1', 1000],
['session-2', 300]
])
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
const result = await scanOpenCodeUsageDatabases([], [])
expect(result.sessions).toHaveLength(2)
const sessionOne = result.sessions.find((session) => session.sessionId === 'session-1')
expect(sessionOne?.totalInputTokens).toBe(1000)
expect(sessionOne?.eventCount).toBe(1)
expect(
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1300)
})
it('still counts backup-only sessions and stays stable across cached rescans', async () => {
const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]])
// The backup duplicates session-1 (stale) and preserves session-9, which
// no longer exists in the canonical database.
writeSessionTotalsDb('opencode-backup.db', [
['session-1', 400],
['session-9', 50]
])
const first = await scanOpenCodeUsageDatabases([], [])
expect(
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1050)
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1050)
// The canonical db keeps growing while the backup stays cached; session-1
// must stay owned by the canonical db.
const db = new Database(canonicalPath)
insertSessionTotalsRow(db, 'session-2', 200)
db.close()
const third = await scanOpenCodeUsageDatabases([], second.processedDatabases)
expect(
third.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1250)
const sessionOne = third.sessions.find((session) => session.sessionId === 'session-1')
expect(sessionOne?.totalInputTokens).toBe(1000)
expect(sessionOne?.eventCount).toBe(1)
})
it('lets the live database reclaim sessions after a sticky backup-only claim', async () => {
// First scan only has the backup (e.g. live db temporarily missing), so it
// owns session-1 at the stale snapshot. When opencode.db reappears with
// higher totals it must reclaim the session instead of staying frozen.
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
const first = await scanOpenCodeUsageDatabases([], [])
expect(
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(400)
expect(first.processedDatabases[0]?.ownedSessionIds).toEqual(['session-1'])
writeSessionTotalsDb('opencode.db', [
['session-1', 1000],
['session-2', 200]
])
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1200)
const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1')
expect(sessionOne?.totalInputTokens).toBe(1000)
expect(sessionOne?.eventCount).toBe(1)
})
it('reclaims sessions when the owning live database is deleted', async () => {
const canonicalPath = writeSessionTotalsDb('opencode.db', [
['session-1', 1000],
['session-2', 200]
])
writeSessionTotalsDb('opencode-backup.db', [
['session-1', 400],
['session-9', 50]
])
const first = await scanOpenCodeUsageDatabases([], [])
expect(
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1250)
unlinkSync(canonicalPath)
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(450)
expect(second.sessions.map((session) => session.sessionId).sort()).toEqual([
'session-1',
'session-9'
])
const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1')
expect(sessionOne?.totalInputTokens).toBe(400)
})
})
+122 -14
View File
@@ -2,7 +2,7 @@
import { existsSync } from 'node:fs'
import { readdir, realpath, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { isAbsolute, join, posix, win32 } from 'node:path'
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import Database from '../sqlite/sync-database'
@@ -121,6 +121,18 @@ export async function listOpenCodeDatabases(): Promise<string[]> {
}
}
function compareOpenCodeClaimPriority(left: string, right: string): number {
// Why: the canonical opencode.db is the live database; it must claim
// duplicated sessions ahead of stale sibling copies. Remaining ties use
// path order so ownership is deterministic across rescans.
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
if (leftRank !== rightRank) {
return leftRank - rightRank
}
return left < right ? -1 : left > right ? 1 : 0
}
export async function getProcessedDatabaseInfo(
dbPath: string
): Promise<OpenCodeUsageProcessedDatabase> {
@@ -831,18 +843,32 @@ function mergeDailyAggregates(
export async function parseOpenCodeUsageDatabase(
dbPath: string,
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[],
options: { claimSession?: (sessionId: string) => boolean } = {}
): Promise<OpenCodeUsagePersistedDatabase> {
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
try {
db.pragma('query_only = ON')
const events: OpenCodeUsageAttributedEvent[] = []
const claimedBySessionId = new Map<string, boolean>()
let hasDeferredClaims = false
for (const row of selectUsageRows(db)) {
const parsed = parseOpenCodeUsageRow(row)
if (!parsed) {
continue
}
// Why: a stale sibling copy of opencode.db carries the same sessions, so
// each session must be counted from exactly one database (#8006).
let owned = claimedBySessionId.get(parsed.sessionId)
if (owned === undefined) {
owned = options.claimSession ? options.claimSession(parsed.sessionId) : true
claimedBySessionId.set(parsed.sessionId, owned)
}
if (!owned) {
hasDeferredClaims = true
continue
}
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
if (attributed) {
events.push(attributed)
@@ -850,7 +876,11 @@ export async function parseOpenCodeUsageDatabase(
}
return {
...processedDatabase,
...aggregateOpenCodeUsage(events)
...aggregateOpenCodeUsage(events),
ownedSessionIds: [...claimedBySessionId.entries()]
.filter(([, owned]) => owned)
.map(([sessionId]) => sessionId),
hasDeferredClaims
}
} finally {
db.close()
@@ -869,29 +899,107 @@ export async function scanOpenCodeUsageDatabases(
const previousByPath = new Map(
previousProcessedDatabases.map((database) => [database.path, database])
)
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
const sessionsById = new Map<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
for (const [index, dbPath] of dbPaths.entries()) {
const currentPaths = new Set(dbPaths)
// Why: when a database that owned sessions is deleted, remaining siblings
// still contain those sessions but their caches record them as unowned.
// Only databases that previously deferred claims can reclaim.
const lostOwnerPath = previousProcessedDatabases.some(
(database) =>
!currentPaths.has(database.path) &&
Array.isArray(database.ownedSessionIds) &&
database.ownedSessionIds.length > 0
)
const reusedByPath = new Map<string, OpenCodeUsagePersistedDatabase>()
const pathsToParse: string[] = []
for (const dbPath of dbPaths) {
const databaseInfo = await getProcessedDatabaseInfo(dbPath)
const previous = previousByPath.get(dbPath)
// When an owner disappears, only deferred-claim databases need reparse.
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
const canReuse =
previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size
const processed = canReuse
? previous
: await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths)
!mustReclaimDeferred &&
previous &&
previous.mtimeMs === databaseInfo.mtimeMs &&
previous.size === databaseInfo.size &&
Array.isArray(previous.ownedSessionIds) &&
typeof previous.hasDeferredClaims === 'boolean'
if (canReuse) {
reusedByPath.set(dbPath, previous)
} else {
pathsToParse.push(dbPath)
}
}
processedDatabases.push(processed)
mergeSessions(sessionsById, processed.sessions)
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
// Why: a sticky backup claim from a scan where opencode.db was missing would
// otherwise freeze a still-growing session at the backup snapshot when the
// live db reappears. Reparse lower-priority siblings whenever a higher-
// priority path is being re-evaluated so the live db can reclaim sessions
// without double-counting cached backup aggregates.
const demotedReusePaths: string[] = []
for (const dbPath of reusedByPath.keys()) {
const higherPriorityParsing = pathsToParse.some(
(candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0
)
if (higherPriorityParsing) {
demotedReusePaths.push(dbPath)
}
}
for (const dbPath of demotedReusePaths) {
reusedByPath.delete(dbPath)
pathsToParse.push(dbPath)
}
// Why: `opencode-*.db` siblings are typically stale copies of `opencode.db`
// (backups), so mergeSessions would double every duplicated session (#8006).
// Each session is counted from exactly one database. The canonical live db
// claims first so a stale backup cannot freeze a still-growing session at
// its snapshot totals; cached databases keep the claims they persisted.
const sessionOwnerById = new Map<string, string>()
for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) {
const previous = reusedByPath.get(dbPath)
for (const sessionId of previous?.ownedSessionIds ?? []) {
if (!sessionOwnerById.has(sessionId)) {
sessionOwnerById.set(sessionId, dbPath)
}
}
}
const parsedByPath = new Map<string, OpenCodeUsagePersistedDatabase>()
const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority)
for (const [index, dbPath] of orderedPathsToParse.entries()) {
const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, {
claimSession: (sessionId) => {
const owner = sessionOwnerById.get(sessionId)
if (owner !== undefined && owner !== dbPath) {
return false
}
sessionOwnerById.set(sessionId, dbPath)
return true
}
})
parsedByPath.set(dbPath, processed)
if ((index + 1) % YIELD_EVERY_DATABASES === 0) {
await yieldToEventLoop()
}
}
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
const sessionsById = new Map<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
for (const dbPath of dbPaths) {
const processed = reusedByPath.get(dbPath) ?? parsedByPath.get(dbPath)
if (!processed) {
continue
}
processedDatabases.push(processed)
mergeSessions(sessionsById, processed.sessions)
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
return {
processedDatabases,
sessions: finalizeSessions(sessionsById),
+7 -3
View File
@@ -19,7 +19,7 @@ import { OpenCodeUsageStore, normalizePersistedState } from './store'
function getDefaultState(): OpenCodeUsagePersistedState {
return {
schemaVersion: 1,
schemaVersion: 2,
worktreeFingerprint: null,
processedDatabases: [],
sessions: [],
@@ -283,7 +283,9 @@ describe('OpenCodeUsageStore', () => {
mtimeMs: 1,
size: 2,
sessions: [makeSession()],
dailyAggregates: [makeDaily()]
dailyAggregates: [makeDaily()],
ownedSessionIds: ['session-1'],
hasDeferredClaims: false
}
],
sessions: [makeSession()],
@@ -300,7 +302,9 @@ describe('OpenCodeUsageStore', () => {
mtimeMs: 1,
size: 2,
sessions: [],
dailyAggregates: []
dailyAggregates: [],
ownedSessionIds: [],
hasDeferredClaims: false
}
]
}).processedDatabases
+3 -1
View File
@@ -18,7 +18,9 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner'
const SCHEMA_VERSION = 1
// Why: v2 adds per-database session ownership (stale sibling-copy dedupe).
// Older caches were built without it and can carry doubled sessions (#8006).
const SCHEMA_VERSION = 2
const STALE_MS = 5 * 60_000
let _openCodeUsageFile: string | null = null
+7
View File
@@ -86,6 +86,13 @@ export type OpenCodeUsageDailyAggregate = {
export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & {
sessions: OpenCodeUsageSession[]
dailyAggregates: OpenCodeUsageDailyAggregate[]
/** Session ids this database counted. Sibling copies (opencode-backup.db)
* duplicate sessions; ownership keeps each session counted by exactly one
* cached database across incremental scans. */
ownedSessionIds: string[]
/** True when this database saw sessions already claimed by another database.
* When that owner disappears, only deferred databases need reparse. */
hasDeferredClaims: boolean
}
export type OpenCodeUsagePersistedState = {
@@ -1,8 +1,5 @@
import { randomUUID } from 'node:crypto'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudCapabilities, OrcaCloudOrgSummary } from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
@@ -138,9 +138,7 @@ describe('Orca cloud profile auth-failure retry', () => {
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
@@ -123,9 +123,7 @@ describe('Orca cloud profile service session refresh', () => {
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
@@ -114,9 +114,7 @@ describe('Orca cloud profile service', () => {
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) =>
Buffer.from(value, 'utf-8')
)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
revokeOrcaCloudSessionMock.mockResolvedValue(undefined)
vi.unstubAllEnvs()
+6
View File
@@ -3094,6 +3094,12 @@ export class Store {
),
settings: {
...defaults.settings,
// Why (#7977): a persisted experimentalNewWorktreeCardStyle:true is
// kept even though the default is now false. The v1.4.130 open-
// onboarding auto-default wrote the same plain boolean as a real
// opt-in, so a rollback migration would also revert genuine opt-ins;
// product intent was only to change the default, and the setting
// stays user-toggleable.
...stripLegacyTerminalScrollbackBytes(parsed.settings),
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
// the old persisted flag forward once so enabled users don't lose it.
+1 -1
View File
@@ -1044,7 +1044,7 @@ export class LocalPtyProvider implements IPtyProvider {
return ptyProcesses.get(id)
}
/** Kill all PTYs. Call on app quit. */
/** Kill all in-process local PTYs. Call on app quit. */
killAll(): void {
for (const [id, proc] of ptyProcesses) {
safeKillAndClean(id, proc)
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import type * as pty from 'node-pty'
import type * as LocalPtyShellReadyModule from './local-pty-shell-ready'
import {
@@ -724,6 +724,31 @@ describePosix('local PTY shell-ready launch config', () => {
// Fallback chain: discovered → normalized spawn-env path → HOME
expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}')
})
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.
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"')
// The runtime path is only trusted when it still holds a wrapper .zshenv;
// otherwise the generation-time literal remains as the fallback.
expect(zshenv).toContain(
'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' +
' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' +
'else\n' +
` export ZDOTDIR='${join(userDataPath, 'shell-ready', 'zsh')}'\n` +
'fi'
)
// Capture must happen before the wrapper unsets ZDOTDIR to source user files.
expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"')).toBeLessThan(
zshenv.indexOf('unset ZDOTDIR')
)
})
})
// Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level
@@ -812,6 +837,61 @@ path=(/custom/bin $path)
expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin')
})
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.
writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n')
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const movedUserData = `${userDataPath}-wsl-view`
renameSync(userDataPath, movedUserData)
try {
const cleanEnv: Record<string, string | undefined> = {
...process.env,
HOME: testHome,
PATH: '/usr/bin:/bin'
}
delete cleanEnv.ZDOTDIR
delete cleanEnv.ORCA_ORIG_ZDOTDIR
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
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.
for (const args of [['-i'], ['-l', '-i']] as const) {
const result = spawnSync(
'zsh',
[
...args,
'-c',
'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"'
],
{
env: cleanEnv as NodeJS.ProcessEnv,
encoding: 'utf8'
}
)
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.
expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no')
}
} finally {
rmSync(movedUserData, { recursive: true, force: true })
}
})
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.
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest'
import { createAuthFilesystemOperation } from './auth-filesystem-operation'
describe('createAuthFilesystemOperation', () => {
it('serializes WSL aliases by distro and drops an abandoned queued read', async () => {
let resolveFirst!: (value: string) => void
const firstRaw = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFirst = resolve
})
)
const secondRaw = vi.fn(async () => 'second')
const first = createAuthFilesystemOperation(
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
firstRaw
)
const second = createAuthFilesystemOperation(
'\\\\WSL$\\ubuntu\\home\\alice\\managed\\auth.json',
secondRaw
)
const firstController = new AbortController()
const secondController = new AbortController()
const firstWait = first.wait(firstController.signal)
const secondWait = second.wait(secondController.signal)
await Promise.resolve()
await Promise.resolve()
expect(firstRaw).toHaveBeenCalledOnce()
expect(secondRaw).not.toHaveBeenCalled()
const queuedAbort = new Error('queued caller expired')
secondController.abort(queuedAbort)
await expect(secondWait).rejects.toBe(queuedAbort)
resolveFirst('first')
await expect(firstWait).resolves.toBe('first')
await expect(second.result).rejects.toBe(queuedAbort)
expect(secondRaw).not.toHaveBeenCalled()
})
it('allows different WSL distros to probe concurrently', async () => {
const ubuntuRaw = vi.fn(async () => 'ubuntu')
const debianRaw = vi.fn(async () => 'debian')
const ubuntu = createAuthFilesystemOperation(
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
ubuntuRaw
)
const debian = createAuthFilesystemOperation(
'\\\\wsl.localhost\\Debian\\home\\alice\\.codex\\auth.json',
debianRaw
)
const controller = new AbortController()
await expect(
Promise.all([ubuntu.wait(controller.signal), debian.wait(controller.signal)])
).resolves.toEqual(['ubuntu', 'debian'])
expect(ubuntuRaw).toHaveBeenCalledOnce()
expect(debianRaw).toHaveBeenCalledOnce()
})
it('does not start an operation for an already-aborted waiter', async () => {
const raw = vi.fn(async () => 'unexpected')
const operation = createAuthFilesystemOperation(
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
raw
)
const controller = new AbortController()
const abortError = new Error('already expired')
controller.abort(abortError)
await expect(operation.wait(controller.signal)).rejects.toBe(abortError)
await expect(operation.result).rejects.toBe(abortError)
expect(raw).not.toHaveBeenCalled()
})
it('caps cross-distro operations and never starts an expired queued probe', async () => {
let resolveUbuntu!: (value: string) => void
let resolveDebian!: (value: string) => void
const ubuntuRaw = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveUbuntu = resolve
})
)
const debianRaw = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveDebian = resolve
})
)
const fedoraRaw = vi.fn(async () => 'fedora')
const ubuntu = createAuthFilesystemOperation('\\\\wsl$\\Ubuntu\\a\\auth.json', ubuntuRaw)
const debian = createAuthFilesystemOperation('\\\\wsl$\\Debian\\b\\auth.json', debianRaw)
const fedora = createAuthFilesystemOperation('\\\\wsl$\\Fedora\\c\\auth.json', fedoraRaw)
const ubuntuController = new AbortController()
const debianController = new AbortController()
const fedoraController = new AbortController()
const ubuntuWait = ubuntu.wait(ubuntuController.signal)
const debianWait = debian.wait(debianController.signal)
const fedoraWait = fedora.wait(fedoraController.signal)
await Promise.resolve()
await Promise.resolve()
expect(ubuntuRaw).toHaveBeenCalledOnce()
expect(debianRaw).toHaveBeenCalledOnce()
expect(fedoraRaw).not.toHaveBeenCalled()
const queuedAbort = new Error('global queue expired')
fedoraController.abort(queuedAbort)
await expect(fedoraWait).rejects.toBe(queuedAbort)
await expect(fedora.result).rejects.toBe(queuedAbort)
resolveUbuntu('ubuntu')
resolveDebian('debian')
await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian'])
expect(fedoraRaw).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,175 @@
import { parseWslUncPath } from '../../shared/wsl-paths'
const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2
const activeWslOperationDistros = new Set<string>()
const queuedWslOperations: QueuedWslOperation<unknown>[] = []
let activeWslOperationCount = 0
type QueuedWslOperation<T> = {
distroKey: string
neededSignal: AbortSignal
operation: () => Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
onAbort: () => void
state: 'queued' | 'running' | 'settled'
}
function getAbortReason(signal: AbortSignal): unknown {
return signal.reason ?? new Error('Auth filesystem operation aborted')
}
function finishWslOperation<T>(task: QueuedWslOperation<T>, settle: () => void): void {
if (task.state !== 'running') {
return
}
task.state = 'settled'
activeWslOperationCount -= 1
activeWslOperationDistros.delete(task.distroKey)
settle()
pumpWslOperations()
}
function pumpWslOperations(): void {
while (activeWslOperationCount < MAX_CONCURRENT_WSL_AUTH_OPERATIONS) {
const nextIndex = queuedWslOperations.findIndex(
(task) => !activeWslOperationDistros.has(task.distroKey)
)
if (nextIndex === -1) {
return
}
const task = queuedWslOperations.splice(nextIndex, 1)[0]
if (!task || task.state !== 'queued') {
continue
}
task.neededSignal.removeEventListener('abort', task.onAbort)
if (task.neededSignal.aborted) {
task.state = 'settled'
task.reject(getAbortReason(task.neededSignal))
continue
}
task.state = 'running'
activeWslOperationCount += 1
activeWslOperationDistros.add(task.distroKey)
void Promise.resolve()
.then(() => {
if (task.neededSignal.aborted) {
throw getAbortReason(task.neededSignal)
}
return task.operation()
})
.then(
(value) => finishWslOperation(task, () => task.resolve(value)),
(error: unknown) => finishWslOperation(task, () => task.reject(error))
)
}
}
function scheduleWslAuthFilesystemOperation<T>(
distroKey: string,
neededSignal: AbortSignal,
operation: () => Promise<T>
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const task: QueuedWslOperation<T> = {
distroKey,
neededSignal,
operation,
resolve,
reject,
state: 'queued',
onAbort: () => {
if (task.state !== 'queued') {
return
}
task.state = 'settled'
const index = queuedWslOperations.indexOf(task as QueuedWslOperation<unknown>)
if (index !== -1) {
queuedWslOperations.splice(index, 1)
}
reject(getAbortReason(neededSignal))
pumpWslOperations()
}
}
neededSignal.addEventListener('abort', task.onAbort, { once: true })
queuedWslOperations.push(task as QueuedWslOperation<unknown>)
queueMicrotask(pumpWslOperations)
})
}
function scheduleAuthFilesystemOperation<T>(
authPath: string,
neededSignal: AbortSignal,
operation: () => Promise<T>
): Promise<T> {
const wslInfo = parseWslUncPath(authPath)
if (!wslInfo) {
return Promise.resolve().then(() => {
if (neededSignal.aborted) {
throw getAbortReason(neededSignal)
}
return operation()
})
}
// Why: a few disconnected distros must not occupy libuv's entire default
// filesystem pool. Distro serialization also folds wsl$/wsl.localhost and
// case aliases without forcing healthy local auth reads through the queue.
return scheduleWslAuthFilesystemOperation(
wslInfo.distro.trim().toLowerCase(),
neededSignal,
operation
)
}
export type SharedAuthFilesystemOperation<T> = {
result: Promise<T>
wait: (signal: AbortSignal) => Promise<T>
}
/**
* Shares one raw operation with all callers for an auth path. WSL paths also
* serialize by normalized distro because one stuck UNC request per account can
* otherwise exhaust libuv's filesystem threadpool.
*/
export function createAuthFilesystemOperation<T>(
authPath: string,
operation: () => Promise<T>
): SharedAuthFilesystemOperation<T> {
const neededController = new AbortController()
const waiters = new Set<symbol>()
let settled = false
const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation)
const markSettled = (): void => {
settled = true
}
void result.then(markSettled, markSettled)
return {
result,
wait(signal) {
if (signal.aborted) {
if (!settled && waiters.size === 0) {
neededController.abort(getAbortReason(signal))
}
return Promise.reject(getAbortReason(signal))
}
const waiter = Symbol('auth-filesystem-waiter')
waiters.add(waiter)
let onAbort: (() => void) | null = null
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(getAbortReason(signal))
signal.addEventListener('abort', onAbort, { once: true })
})
return Promise.race([result, aborted]).finally(() => {
if (onAbort) {
signal.removeEventListener('abort', onAbort)
}
waiters.delete(waiter)
if (!settled && waiters.size === 0) {
neededController.abort(getAbortReason(signal))
}
})
}
}
}
@@ -1,13 +1,13 @@
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, homedirMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
const { accessMock, homedirMock } = vi.hoisted(() => ({
accessMock: vi.fn(),
homedirMock: vi.fn()
}))
vi.mock('node:fs', () => ({
existsSync: existsSyncMock
vi.mock('node:fs/promises', () => ({
access: accessMock
}))
vi.mock('node:os', () => ({
@@ -33,33 +33,101 @@ describe('codexAuthExists', () => {
}
})
it('checks an explicit managed-account home first', () => {
existsSyncMock.mockReturnValue(true)
it('checks an explicit managed-account home first', async () => {
accessMock.mockResolvedValue(undefined)
expect(codexAuthExists('/managed/home')).toBe(true)
expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json'))
await expect(codexAuthExists('/managed/home')).resolves.toBe(true)
expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json'))
})
it('falls back to CODEX_HOME when no home is provided', () => {
it('falls back to CODEX_HOME when no home is provided', async () => {
process.env.CODEX_HOME = '/custom/codex'
existsSyncMock.mockReturnValue(true)
accessMock.mockResolvedValue(undefined)
expect(codexAuthExists()).toBe(true)
expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json'))
await expect(codexAuthExists()).resolves.toBe(true)
expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json'))
})
it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => {
existsSyncMock.mockReturnValue(false)
it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => {
accessMock.mockRejectedValue(new Error('ENOENT'))
expect(codexAuthExists()).toBe(false)
expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json'))
await expect(codexAuthExists()).resolves.toBe(false)
expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json'))
})
it('returns false instead of throwing when the fs check fails', () => {
existsSyncMock.mockImplementation(() => {
throw new Error('EACCES')
it('returns false instead of throwing when the fs check fails', async () => {
accessMock.mockRejectedValue(new Error('EACCES'))
await expect(codexAuthExists('/managed/home')).resolves.toBe(false)
})
it('stops waiting for a stalled filesystem check when the caller aborts', async () => {
let resolveAccess!: () => void
accessMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveAccess = resolve
})
)
const controller = new AbortController()
const result = codexAuthExists('/managed/home', { signal: controller.signal })
await Promise.resolve()
await Promise.resolve()
controller.abort()
await expect(result).resolves.toBe(false)
resolveAccess()
await Promise.resolve()
})
it('bounds a stalled filesystem check even without a caller signal', async () => {
let resolveAccess!: () => void
accessMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveAccess = resolve
})
)
const timeoutController = new AbortController()
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal)
const result = codexAuthExists('/managed/home')
expect(timeout).toHaveBeenCalledWith(5_000)
await Promise.resolve()
await Promise.resolve()
timeoutController.abort()
await expect(result).resolves.toBe(false)
resolveAccess()
await Promise.resolve()
timeout.mockRestore()
})
it('shares one stalled UNC probe across concurrent callers', async () => {
let resolveAccess!: () => void
accessMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveAccess = resolve
})
)
const firstController = new AbortController()
const secondController = new AbortController()
const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
signal: firstController.signal
})
const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
signal: secondController.signal
})
expect(codexAuthExists('/managed/home')).toBe(false)
await Promise.resolve()
await Promise.resolve()
expect(accessMock).toHaveBeenCalledTimes(1)
firstController.abort()
secondController.abort()
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
resolveAccess()
await Promise.resolve()
})
})
+48 -3
View File
@@ -1,6 +1,42 @@
import { existsSync } from 'node:fs'
import { access } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createAuthFilesystemOperation,
type SharedAuthFilesystemOperation
} from './auth-filesystem-operation'
const AUTH_PRESENCE_TIMEOUT_MS = 5_000
const authPresenceProbeByPath = new Map<string, SharedAuthFilesystemOperation<boolean>>()
type CodexAuthPresenceOptions = {
signal?: AbortSignal
timeoutMs?: number
}
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<boolean> {
const existing = authPresenceProbeByPath.get(authPath)
if (existing) {
return existing
}
// Why: aborting a Node fs promise does not necessarily cancel an already
// issued UNC operation. Share the raw probe until it really settles so a
// disconnected WSL home cannot accumulate native requests across polls.
const probe = createAuthFilesystemOperation(authPath, () =>
access(authPath).then(
() => true,
() => false
)
)
authPresenceProbeByPath.set(authPath, probe)
const clearProbe = (): void => {
if (authPresenceProbeByPath.get(authPath) === probe) {
authPresenceProbeByPath.delete(authPath)
}
}
void probe.result.then(clearProbe, clearProbe)
return probe
}
// Why: the background quota poller spawns the real `codex` binary to read rate
// limits. For users who installed Codex but never signed in, that spawn can
@@ -8,12 +44,21 @@ import { join } from 'node:path'
// the background. A signed-in Codex always writes an auth.json under its
// CODEX_HOME, so gating the fetch on that file keeps the poller silent until
// the user actually uses Codex.
export function codexAuthExists(codexHomePath?: string | null): boolean {
export async function codexAuthExists(
codexHomePath?: string | null,
options: CodexAuthPresenceOptions = {}
): Promise<boolean> {
// Mirror Codex's own home resolution: an explicit managed-account home wins,
// then CODEX_HOME, then the default ~/.codex.
const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
const authPath = join(home, 'auth.json')
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_PRESENCE_TIMEOUT_MS)
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal
try {
return existsSync(join(home, 'auth.json'))
// Why: managed WSL homes are UNC paths. A synchronous stat can park
// Electron main while Windows wakes or reconnects the distro; the race
// also keeps a disconnected distro from serializing all later refreshes.
return await getAuthPresenceProbe(authPath).wait(signal)
} catch {
return false
}
@@ -0,0 +1,194 @@
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
readFileMock: vi.fn(),
ptySpawnMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
vi.mock('node-pty', () => ({ spawn: ptySpawnMock }))
vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) }))
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
describe('Codex backend rate-limit requests', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('uses the official backend usage contract without spawning Codex', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 12,
limit_window_seconds: 3_600,
reset_at: 1_800_000_000
},
secondary_window: {
used_percent: 34,
limit_window_seconds: 86_400,
reset_at: 1_800_100_000
}
},
rate_limit_reset_credits: { available_count: 1 }
})
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
available_count: 1,
credits: [
{
status: 'available',
expires_at: '2027-01-15T12:00:00Z',
granted_at: '2027-01-08T12:00:00Z'
}
]
})
} as Response)
await expect(
fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
).resolves.toMatchObject({
session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 },
weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 },
rateLimitResetCredits: {
availableCount: 1,
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
},
status: 'ok'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://chatgpt.com/backend-api/wham/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' }),
signal: expect.any(AbortSignal)
})
)
})
it('aborts callers while sharing one stalled backend auth read', async () => {
let resolveRead!: (content: string) => void
readFileMock.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveRead = resolve
})
)
const firstController = new AbortController()
const secondController = new AbortController()
const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal })
const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal })
await vi.advanceTimersByTimeAsync(0)
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8')
firstController.abort()
secondController.abort()
await expect(first).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
await expect(second).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(fetch).not.toHaveBeenCalled()
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
resolveRead('{}')
await vi.advanceTimersByTimeAsync(0)
})
it('applies the backend deadline while auth reading is still pending', async () => {
let resolveRead!: (content: string) => void
readFileMock.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveRead = resolve
})
)
const timeoutController = new AbortController()
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal)
const deadlineError = new Error('backend deadline')
const result = consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/deadline-home',
idempotencyKey: 'redeem-timeout'
})
await vi.advanceTimersByTimeAsync(0)
// Why: redeem is user-triggered, so it gets the longer redeem deadline.
expect(timeout).toHaveBeenCalledWith(30_000)
expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8')
timeoutController.abort(deadlineError)
await expect(result).rejects.toBe(deadlineError)
resolveRead('{}')
await vi.advanceTimersByTimeAsync(0)
})
it('consumes a reset credit with the official payload and bounded request signal', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ code: 'already_redeemed' })
} as Response)
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/codex-home',
idempotencyKey: 'redeem-123'
})
).resolves.toBe('alreadyRedeemed')
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ redeem_request_id: 'redeem-123' }),
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
'ChatGPT-Account-Id': 'account-id',
'Content-Type': 'application/json'
})
})
)
})
})
+34 -2
View File
@@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
vi.mocked(codexAuthExists).mockReturnValue(true)
vi.mocked(codexAuthExists).mockResolvedValue(true)
readFileMock.mockRejectedValue(new Error('no auth fixture'))
vi.stubGlobal('fetch', vi.fn())
})
@@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => {
})
it('does not spawn Codex when the user is not signed in', async () => {
vi.mocked(codexAuthExists).mockReturnValue(false)
vi.mocked(codexAuthExists).mockResolvedValue(false)
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
provider: 'codex',
@@ -101,6 +101,28 @@ describe('fetchCodexRateLimits', () => {
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('preserves the aborted result when cancellation lands during the auth check', async () => {
let resolveAuth!: (exists: boolean) => void
vi.mocked(codexAuthExists).mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveAuth = resolve
})
)
const controller = new AbortController()
const result = fetchCodexRateLimits({ signal: controller.signal })
controller.abort()
resolveAuth(false)
await expect(result).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
@@ -133,6 +155,7 @@ describe('fetchCodexRateLimits', () => {
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
@@ -168,6 +191,7 @@ describe('fetchCodexRateLimits', () => {
const controller = new AbortController()
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
await vi.advanceTimersByTimeAsync(0)
controller.abort()
@@ -221,6 +245,7 @@ describe('fetchCodexRateLimits', () => {
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
@@ -247,6 +272,7 @@ describe('fetchCodexRateLimits', () => {
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
@@ -421,6 +447,7 @@ describe('fetchCodexRateLimits', () => {
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
expect.objectContaining({
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
'ChatGPT-Account-Id': 'account-id',
@@ -553,6 +580,8 @@ describe('fetchCodexRateLimits', () => {
)
expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
expect(shellCommand).not.toContain('_orca_codex')
expect(shellCommand).not.toContain('wsl-codex-path')
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: expect.stringContaining('rate-limit-pty-cwd'),
@@ -649,6 +678,7 @@ describe('fetchCodexRateLimits', () => {
const resultPromise = fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
@@ -668,6 +698,8 @@ describe('fetchCodexRateLimits', () => {
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
)
expect(shellCommand).toContain('exec codex ')
expect(shellCommand).not.toContain('_orca_codex')
expect(shellCommand).not.toContain('wsl-codex-path')
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: expect.stringContaining('rate-limit-pty-cwd'),
+195 -19
View File
@@ -25,10 +25,18 @@ import {
getHiddenRateLimitWslCwdSetupCommands,
resolveHiddenRateLimitPtyCwd
} from './hidden-rate-limit-pty-cwd'
import {
createAuthFilesystemOperation,
type SharedAuthFilesystemOperation
} from './auth-filesystem-operation'
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.
const REDEEM_BACKEND_TIMEOUT_MS = 30_000
const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
export type FetchCodexRateLimitsOptions = {
@@ -102,6 +110,21 @@ type BackendRateLimitResetCreditsResponse = {
}[]
}
type BackendRateLimitWindow = {
used_percent?: number
limit_window_seconds?: number
reset_at?: number
}
type BackendUsageResponse = {
plan_type?: string
rate_limit?: {
primary_window?: BackendRateLimitWindow | null
secondary_window?: BackendRateLimitWindow | null
} | null
rate_limit_reset_credits?: BackendRateLimitResetCreditsResponse | null
}
type BackendConsumeRateLimitResetCreditResponse = {
code?: string
}
@@ -110,6 +133,15 @@ type CodexBackendAuthHeaders = {
headers: Record<string, string>
}
type BackendAuthReadResult =
| { content: string; error?: never }
| { content?: never; error: unknown }
const backendAuthReadByPath = new Map<
string,
SharedAuthFilesystemOperation<BackendAuthReadResult>
>()
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
@@ -126,14 +158,19 @@ function buildWslCodexCommand(
if (process.platform !== 'win32' || !wslInfo) {
return null
}
const script = [
const setupCommands = [
...getHiddenRateLimitWslCwdSetupCommands(),
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
`exec codex ${args.map(shellQuote).join(' ')}${
options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
}`
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`
].join(' && ')
const loginShellCommand = buildWslLoginShellCommand(script)
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.
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.
const command = options?.isolateRpcStdio
@@ -255,14 +292,57 @@ function mapBackendRateLimitResetCredits(
}
}
function createBackendRequestSignal(
callerSignal?: AbortSignal,
timeoutMs = BACKEND_TIMEOUT_MS
): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
}
function getBackendAuthRead(
authPath: string
): SharedAuthFilesystemOperation<BackendAuthReadResult> {
const existing = backendAuthReadByPath.get(authPath)
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.
const read = createAuthFilesystemOperation(authPath, () =>
readFile(authPath, 'utf8').then(
(content) => ({ content }),
(error: unknown) => ({ error })
)
)
backendAuthReadByPath.set(authPath, read)
const clearRead = (): void => {
if (backendAuthReadByPath.get(authPath) === read) {
backendAuthReadByPath.delete(authPath)
}
}
void read.result.then(clearRead, clearRead)
return read
}
async function readBackendAuth(authPath: string, signal: AbortSignal): Promise<string> {
const result = await getBackendAuthRead(authPath).wait(signal)
if ('error' in result) {
throw result.error
}
return result.content
}
async function getCodexBackendAuthHeaders(
options?: FetchCodexRateLimitsOptions
options: FetchCodexRateLimitsOptions | undefined,
signal: AbortSignal
): Promise<CodexBackendAuthHeaders | null> {
if (options?.signal?.aborted) {
if (signal.aborted) {
return null
}
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
const auth = JSON.parse(await readBackendAuth(authPath, signal)) as CodexAuthFile
const accessToken = auth.tokens?.access_token
if (!accessToken) {
return null
@@ -286,18 +366,19 @@ async function fetchBackendRateLimitResetCredits(
if (options?.signal?.aborted) {
return null
}
const auth = await getCodexBackendAuthHeaders(options)
const signal = createBackendRequestSignal(options?.signal)
const auth = await getCodexBackendAuthHeaders(options, signal)
if (!auth) {
return null
}
if (options?.signal?.aborted) {
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.
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
...auth,
signal: options?.signal
signal
})
if (!response.ok) {
return null
@@ -349,7 +430,8 @@ export async function consumeCodexRateLimitResetCredit(options: {
if (!options.idempotencyKey.trim()) {
throw new Error('Codex reset idempotency key is required')
}
const auth = await getCodexBackendAuthHeaders(options)
const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS)
const auth = await getCodexBackendAuthHeaders(options, signal)
if (!auth) {
throw new Error('Codex not signed in')
}
@@ -361,7 +443,8 @@ export async function consumeCodexRateLimitResetCredit(options: {
...auth.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ redeem_request_id: options.idempotencyKey })
body: JSON.stringify({ redeem_request_id: options.idempotencyKey }),
signal
}
)
if (!response.ok) {
@@ -375,13 +458,13 @@ function mapRpcWindow(
raw: RpcRateWindow | undefined,
expectedWindowMinutes: number
): RateLimitWindow | null {
if (!raw || typeof raw.usedPercent !== 'number') {
if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) {
return null
}
let resetDescription: string | null = null
let resetsAt: number | null = null
if (raw.resetsAt) {
if (typeof raw.resetsAt === 'number' && Number.isFinite(raw.resetsAt) && raw.resetsAt > 0) {
// Why: Codex returns resetsAt as Unix seconds, not milliseconds.
const date = new Date(raw.resetsAt * 1000)
if (!Number.isNaN(date.getTime())) {
@@ -408,6 +491,71 @@ function mapRpcWindow(
}
}
function mapBackendUsageWindow(
raw: BackendRateLimitWindow | null | undefined,
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.
const windowMinutes =
typeof limitWindowSeconds === 'number' &&
Number.isFinite(limitWindowSeconds) &&
limitWindowSeconds > 0
? Math.ceil(limitWindowSeconds / 60)
: fallbackWindowMinutes
return mapRpcWindow(
raw
? {
usedPercent: raw.used_percent,
resetsAt: raw.reset_at
}
: undefined,
windowMinutes
)
}
async function fetchViaBackend(
options?: FetchCodexRateLimitsOptions
): Promise<ProviderRateLimits | null> {
const signal = createBackendRequestSignal(options?.signal)
const auth = await getCodexBackendAuthHeaders(options, signal)
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.
const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
...auth,
signal
})
if (!response.ok) {
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.
if (typeof payload.plan_type !== 'string') {
return null
}
return {
provider: 'codex',
session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300),
weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080),
...(payload.rate_limit_reset_credits !== undefined
? {
rateLimitResetCredits:
mapBackendRateLimitResetCredits(payload.rate_limit_reset_credits) ?? null
}
: {}),
updatedAt: Date.now(),
error: null,
status: 'ok'
}
}
// ---------------------------------------------------------------------------
// RPC fetch — spawn `codex -s read-only -a untrusted app-server`
// ---------------------------------------------------------------------------
@@ -878,7 +1026,13 @@ export async function fetchCodexRateLimits(
// 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.
if (!codexAuthExists(options?.codexHomePath)) {
const hasCodexAuth = await codexAuthExists(options?.codexHomePath, {
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (!hasCodexAuth) {
return {
provider: 'codex',
session: null,
@@ -889,7 +1043,29 @@ export async function fetchCodexRateLimits(
}
}
// Path A: try RPC first
// 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.
if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) {
try {
const backendResult = await fetchViaBackend(options)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (backendResult) {
const withResetCredits = await withBackendRateLimitResetCredits(backendResult, options)
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
}
} catch {
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.
}
}
// Path B: try RPC
try {
const rpcResult = await fetchViaRpc(options)
if (options?.signal?.aborted) {
@@ -924,7 +1100,7 @@ export async function fetchCodexRateLimits(
// RPC failed — fall through to PTY
}
// Path B: PTY fallback
// Path C: PTY fallback
try {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
+103 -5
View File
@@ -14907,7 +14907,24 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId]
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
getWorkspaceSession: () => ({
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: {
'tab-1:leaf-1': {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
agent: 'codex',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'test',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
}
})
} as never)
runtime.setNotifier({
worktreesChanged: vi.fn(),
@@ -14924,13 +14941,13 @@ describe('OrcaRuntimeService', () => {
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
// A renderer must be attached to receive the wake (headless serve is a
// deliberate non-goal — no renderer, no wake).
// A renderer must be attached to receive the wake — headless serve reports
// the explicit 'unsupported-headless' outcome instead (see below).
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'mobile'
})
@@ -14939,6 +14956,86 @@ describe('OrcaRuntimeService', () => {
// routes exclusively through the renderer's own navigation-free wake.
expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID)
expect(activateWorktree).not.toHaveBeenCalled()
expect(result.sleepingAgentWake).toBe('requested')
})
it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => {
// Why: without an attached renderer nothing holds the sleeping records, so
// no agent actually wakes. The result must say so explicitly — activating
// silently would let the phone present slept agents as resumed (#7906).
const metaById: Record<string, WorktreeMeta> = {
[TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false })
}
const resumeSleepingAgents = vi.fn()
const getWorkspaceSession = vi.fn(() => ({
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: {
'tab-1:leaf-1': {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
agent: 'codex',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'test',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
}
}))
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
getWorkspaceSession
} as never)
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
resumeSleepingAgents,
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
// Headless/no-live-renderer: the graph is ready but no BrowserWindow backs
// the authoritative id, so getAvailableAuthoritativeWindow() is null.
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'mobile'
})
expect(result.activated).toBe(true)
expect(result.sleepingAgentWake).toBe('unsupported-headless')
// Why: sleeping records are host-partitioned; the check must read the
// repo's execution host partition, not always the local one.
expect(getWorkspaceSession).toHaveBeenCalledWith('local')
expect(resumeSleepingAgents).not.toHaveBeenCalled()
})
it('does not report headless wake degradation without sleeping records', async () => {
const runtime = new OrcaRuntimeService(store as never)
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'mobile'
})
expect(result.sleepingAgentWake).toBe('not-applicable')
})
it('does not wake slept agents for non-mobile session-only activation', async () => {
@@ -14972,12 +15069,13 @@ describe('OrcaRuntimeService', () => {
// INV-3: web/desktop runtime clients keep their existing wake-on-activation
// paths untouched — the renderer notifier wake is mobile-scoped.
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'runtime'
})
expect(resumeSleepingAgents).not.toHaveBeenCalled()
expect(result.sleepingAgentWake).toBe('not-applicable')
})
it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => {
+39 -9
View File
@@ -128,7 +128,11 @@ import type {
DirEntry
} from '../../shared/types'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal'
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
@@ -477,7 +481,8 @@ import {
updateIssueForAgent as updateLinearIssueForAgent,
updateIssue as updateLinearIssue,
LinearWriteFailure,
type LinearListFilter
type LinearListFilter,
type LinearIssueListOptions
} from '../linear/issues'
import {
LinearAgentAccessError,
@@ -12627,6 +12632,10 @@ export class OrcaRuntimeService {
repoId: string
worktreeId: string
activated: boolean
/** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no
* renderer holds the sleeping records (headless `orca serve`), so nothing
* woke — clients must not present the worktree's agents as resumed. */
sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable'
}> {
this.assertGraphReady()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
@@ -12642,6 +12651,8 @@ export class OrcaRuntimeService {
this.notifyWorktreesChanged(repo.id)
}
let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' =
'not-applicable'
if (opts.notifyClients !== false) {
// Why: inactive worktree terminal panes are renderer-owned and may not have
// live PTYs until the desktop activates the worktree and mounts them.
@@ -12657,12 +12668,29 @@ export class OrcaRuntimeService {
// Why: a phone open must also wake the worktree's slept agents (experimental
// agent sleep). Only the host renderer holds the sleeping records + wake
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
// unaffected, and only when a renderer is attached to receive it.
if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
// unaffected. Headless serve has no renderer to wake anything, so report
// that explicitly instead of letting mobile assume the agents resumed.
if (opts.clientKind === 'mobile') {
if (this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
sleepingAgentWake = 'requested'
} else if (
// Why: sleeping records are partitioned by execution host; reading
// only the local partition would miss slept agents on SSH-host
// worktrees and skip the headless warning for them.
Object.values(
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
.sleepingAgentSessionsByPaneKey ?? {}
).some((record) => record.worktreeId === worktree.id)
) {
// Why: headless is only degraded when this worktree actually has a
// persisted resume record. Ordinary mobile activation must not show
// an unsupported warning merely because no desktop window is open.
sleepingAgentWake = 'unsupported-headless'
}
}
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true }
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
}
private async buildStartupForDraft(
@@ -20814,7 +20842,9 @@ export class OrcaRuntimeService {
: null
const workspaceId = team?.workspaceId ?? params.workspaceId
try {
const result = await listLinearIssues(filter, limit, workspaceId, team?.id)
const result = await listLinearIssues(filter, limit, workspaceId, {
teamId: team?.id
})
return {
issues: result.items.map((issue) => ({
id: issue.id,
@@ -20942,9 +20972,9 @@ export class OrcaRuntimeService {
filter?: LinearListFilter,
limit = 20,
workspaceId?: LinearWorkspaceSelection,
teamId?: string
options?: LinearIssueListOptions
): ReturnType<typeof listLinearIssues> {
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId)
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options)
}
linearCreateIssue(
@@ -0,0 +1,35 @@
import { z } from 'zod'
import {
LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
} from '../../../../shared/linear-issue-attribute-filter'
// Why: keep ListIssues param validation co-located with shared limits without
// pushing linear.ts past the max-lines ratchet.
const LinearAttributeFilterId = z
.string()
.trim()
.min(1)
.max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH)
export const LinearIssueAttributeFilterSchema = z
.object({
stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS),
priorities: z
.array(z.number().int().min(0).max(4))
.max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES),
assignee: z.union([
z.object({ kind: z.literal('unassigned') }).strict(),
z
.object({
kind: z.literal('user'),
id: LinearAttributeFilterId
})
.strict(),
z.null()
]),
labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
})
.strict()
+3 -1
View File
@@ -102,7 +102,9 @@ describe('linear RPC methods', () => {
)
expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30, 'all')
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1')
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1', {
attributeFilter: undefined
})
expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3', 'workspace-1')
expect(runtime.linearCreateIssue).toHaveBeenCalledWith(
'team-1',
+7 -2
View File
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
@@ -37,8 +38,10 @@ const ListIssues = z
.object({
filter: z.enum(VALID_FILTERS).optional(),
limit: OptionalFiniteNumber,
workspaceId: OptionalString
workspaceId: OptionalString,
attributeFilter: LinearIssueAttributeFilterSchema.optional()
})
.strict()
.optional()
const CreateIssue = z.object({
@@ -164,7 +167,9 @@ export const LINEAR_METHODS: RpcMethod[] = [
name: 'linear.listIssues',
params: ListIssues,
handler: async (params, { runtime }) =>
runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId)
runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId, {
attributeFilter: params?.attributeFilter
})
}),
defineMethod({
name: 'linear.createIssue',
+15 -2
View File
@@ -10,6 +10,13 @@ export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
? `Orca ${headerPrefix} zsh shell-ready wrapper`
: 'Orca zsh shell-ready wrapper'
return `# ${header}
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
# file is generated with a Windows path but sourced via /mnt/c, so the baked
# literal is unusable there and ZDOTDIR must be restored from this value.
_orca_wrapper_zdotdir_self="\${ZDOTDIR:-}"
while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do
_orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}"
done
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
@@ -67,8 +74,14 @@ case "\${ORCA_ORIG_ZDOTDIR}" in
""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
export ZDOTDIR=${quotePosixSingle(zshDir)}
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir
# Why: use :- after user .zshenv — a pathological unset under set -u must not
# abort the wrapper; empty falls through to the baked-literal branch.
if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then
export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}"
else
export ZDOTDIR=${quotePosixSingle(zshDir)}
fi
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self
`
}
+20
View File
@@ -239,6 +239,26 @@ describe('SttService', () => {
expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0)
})
it('drops in-flight audio while stop is waiting for the worker', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop:1')
const worker = getLastWorker()
expect(worker).toBeDefined()
worker!.emitStoppedOnStop = false
const stopPromise = service.stopDictation('desktop:1')
await Promise.resolve()
service.feedAudio(new Float32Array([1]), 16000, 'desktop:1')
expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0)
worker!.emit('message', { type: 'stopped' })
await stopPromise
})
it('rejects deletion prep while the target model is starting', async () => {
let resolveModelState: (state: { id: string; status: string }) => void = () => {}
const modelStatePromise = new Promise<{ id: string; status: string }>((resolve) => {
+91 -79
View File
@@ -35,6 +35,10 @@ export class SttService {
private canceledOwners = new Set<string>()
private eventSink: SttEventSink | null = null
private idleTeardownTimer: NodeJS.Timeout | null = null
// Why: stop resolves only after the worker flushes; in-flight feedAudio IPC
// must not enqueue samples after that flush or they stick on the warm worker
// and contaminate the next dictation session.
private stopping = false
// Why: warm workers intentionally keep lifecycle listeners while reusable;
// stale workers must not retain this service after error, exit, or teardown.
private cleanupWorkerLifecycleListeners: (() => void) | null = null
@@ -259,6 +263,9 @@ export class SttService {
}
feedAudio(samples: Float32Array, sampleRate: number, owner = 'desktop'): void {
if (this.stopping) {
return
}
const currentOwner = this.activeOwner ?? this.startingOwner
if (!currentOwner) {
return
@@ -288,92 +295,97 @@ export class SttService {
throw new Error('dictation_owner_mismatch')
}
if (this.cloudSession) {
const session = this.cloudSession
this.cloudSession = null
try {
const text = await session.finish()
if (text) {
this.eventSink?.({ type: 'final', text })
this.stopping = true
try {
if (this.cloudSession) {
const session = this.cloudSession
this.cloudSession = null
try {
const text = await session.finish()
if (text) {
this.eventSink?.({ type: 'final', text })
}
} catch (error) {
this.eventSink?.({
type: 'error',
error: error instanceof Error ? error.message : String(error)
})
} finally {
this.eventSink?.({ type: 'stopped' })
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
}
} catch (error) {
this.eventSink?.({
type: 'error',
error: error instanceof Error ? error.message : String(error)
})
} finally {
this.eventSink?.({ type: 'stopped' })
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
}
return
}
const worker = this.worker
if (!worker) {
return
}
worker.postMessage({ type: 'stop' })
let forcedTeardown = false
await new Promise<void>((resolve) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
worker.off('message', onStopped)
return
}
const finish = (): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve()
const worker = this.worker
if (!worker) {
return
}
worker.postMessage({ type: 'stop' })
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
if (msg.type === 'stopped') {
finish()
let forcedTeardown = false
await new Promise<void>((resolve) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
worker.off('message', onStopped)
}
const finish = (): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve()
}
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
if (msg.type === 'stopped') {
finish()
}
}
timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
forcedTeardown = true
cleanup()
// Why: a worker that cannot finish dictation is no longer reusable; do
// not keep it in the warm-worker slot or retain its message listeners.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
void worker.terminate().finally(resolve)
}, STOP_DICTATION_TIMEOUT_MS)
worker.on('message', onStopped)
})
if (this.worker === worker) {
if (forcedTeardown) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
} else {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
}
timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
forcedTeardown = true
cleanup()
// Why: a worker that cannot finish dictation is no longer reusable; do
// not keep it in the warm-worker slot or retain its message listeners.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
void worker.terminate().finally(resolve)
}, STOP_DICTATION_TIMEOUT_MS)
worker.on('message', onStopped)
})
if (this.worker === worker) {
if (forcedTeardown) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
} else {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
} finally {
this.stopping = false
}
}
@@ -0,0 +1,80 @@
import { readdirSync } from 'node:fs'
// Why: different models name their ONNX files differently (e.g.
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
// We resolve the actual path from the manifest's files list by searching
// for the role name anywhere in the filename.
export function resolveFile(
files: string[],
role: string,
modelDir: string,
ext = '.onnx'
): string {
const match = files.find((f) => f.includes(role) && f.endsWith(ext))
if (!match) {
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
}
export function resolveTokens(files: string[], modelDir: string): string {
const match = files.find((f) => f.endsWith('tokens.txt'))
if (!match) {
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
}
// Why: BPE models need a vocab file for hotwords token matching. The file
// ships in the model archive but isn't listed in the manifest. We discover
// it at runtime to avoid breaking existing downloads.
function discoverBpeVocab(modelDir: string): string | undefined {
try {
const entries = readdirSync(modelDir)
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
} catch {
return undefined
}
}
export type HotwordsConfig = {
decodingMethod: string
hotwordsFile?: string
hotwordsScore?: number
modelingUnit?: string
bpeVocab?: string
}
export function buildHotwordsConfig(opts: {
modelDir: string
modelType: string
hotwordsFilePath?: string
modelingUnit?: string
}): HotwordsConfig {
if (opts.modelType !== 'transducer' || !opts.hotwordsFilePath) {
return { decodingMethod: 'greedy_search' }
}
const unit = opts.modelingUnit
if (unit?.includes('bpe')) {
const bpeVocab = discoverBpeVocab(opts.modelDir)
if (!bpeVocab) {
return { decodingMethod: 'greedy_search' }
}
return {
decodingMethod: 'modified_beam_search',
hotwordsFile: opts.hotwordsFilePath,
hotwordsScore: 1.5,
modelingUnit: unit,
bpeVocab
}
}
return {
decodingMethod: 'modified_beam_search',
hotwordsFile: opts.hotwordsFilePath,
hotwordsScore: 1.5,
modelingUnit: unit
}
}
+48 -81
View File
@@ -1,8 +1,8 @@
/* oxlint-disable typescript-eslint/no-explicit-any -- sherpa-onnx native addon has no type definitions */
import { parentPort, workerData } from 'node:worker_threads'
import { readdirSync } from 'node:fs'
import { resampleToRate } from './stt-audio-resample'
import { OfflineAudioChunker } from './stt-offline-audio-chunker'
import { buildHotwordsConfig, resolveFile, resolveTokens } from './stt-worker-model-config'
type WorkerMessage =
| {
@@ -39,73 +39,6 @@ function loadSherpa(): any {
return require(modulePath)
}
// Why: different models name their ONNX files differently (e.g.
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
// We resolve the actual path from the manifest's files list by searching
// for the role name anywhere in the filename.
function resolveFile(files: string[], role: string, modelDir: string, ext = '.onnx'): string {
const match = files.find((f) => f.includes(role) && f.endsWith(ext))
if (!match) {
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
}
function resolveTokens(files: string[], modelDir: string): string {
const match = files.find((f) => f.endsWith('tokens.txt'))
if (!match) {
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
}
// Why: BPE models need a vocab file for hotwords token matching. The file
// ships in the model archive but isn't listed in the manifest. We discover
// it at runtime to avoid breaking existing downloads.
function discoverBpeVocab(modelDir: string): string | undefined {
try {
const entries = readdirSync(modelDir)
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
} catch {
return undefined
}
}
function buildHotwordsConfig(msg: Extract<WorkerMessage, { type: 'init' }>): {
decodingMethod: string
hotwordsFile?: string
hotwordsScore?: number
modelingUnit?: string
bpeVocab?: string
} {
if (msg.modelType !== 'transducer' || !msg.hotwordsFilePath) {
return { decodingMethod: 'greedy_search' }
}
const unit = msg.modelingUnit
if (unit?.includes('bpe')) {
const bpeVocab = discoverBpeVocab(msg.modelDir)
if (!bpeVocab) {
return { decodingMethod: 'greedy_search' }
}
return {
decodingMethod: 'modified_beam_search',
hotwordsFile: msg.hotwordsFilePath,
hotwordsScore: 1.5,
modelingUnit: unit,
bpeVocab
}
}
return {
decodingMethod: 'modified_beam_search',
hotwordsFile: msg.hotwordsFilePath,
hotwordsScore: 1.5,
modelingUnit: unit
}
}
function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
try {
sherpa = loadSherpa()
@@ -204,15 +137,33 @@ function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
}
}
// Why: an offline stream is single-use — decode one bounded chunk, then
// recreate the stream so the recognizer is ready for the next chunk.
// Why: an offline stream is single-use — always mint a fresh stream after an
// attempt so a failed decode cannot leave a spent stream on a warm worker.
function decodeOfflineChunk(samples: Float32Array): string {
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples })
sherpa.decodeOfflineStream(recognizer, stream)
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
stream = sherpa.createOfflineStream(recognizer)
const result = JSON.parse(resultJson)
return result?.text?.trim() ?? ''
try {
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples })
sherpa.decodeOfflineStream(recognizer, stream)
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
const result = JSON.parse(resultJson)
return result?.text?.trim() ?? ''
} finally {
if (sherpa && recognizer) {
stream = sherpa.createOfflineStream(recognizer)
}
}
}
// Why: warm-worker reuse must not see residual audio or a spent offline stream.
// Recovery failures are swallowed so they cannot skip the stopped lifecycle signal.
function resetOfflineSessionState(): void {
try {
offlineChunker = new OfflineAudioChunker(offlineSampleRate)
if (sherpa && recognizer) {
stream = sherpa.createOfflineStream(recognizer)
}
} catch {
// Non-fatal: prefer posting stopped over stranding stopDictation for 60s.
}
}
function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
@@ -253,12 +204,23 @@ function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
// the whole app (#7925). Decode bounded chunks as they fill instead;
// each consumer already appends multiple 'final' segments per session.
const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? []
// Why: keep decoding later ready windows after one failure — push() has
// already removed them from the chunker, and decodeOfflineChunk refreshes
// the stream in finally so a spent stream cannot poison the next attempt.
let firstError: unknown = null
for (const chunk of readyChunks) {
const text = decodeOfflineChunk(chunk)
if (text) {
parentPort?.postMessage({ type: 'final', text })
try {
const text = decodeOfflineChunk(chunk)
if (text) {
parentPort?.postMessage({ type: 'final', text })
}
} catch (err) {
firstError ??= err
}
}
if (firstError) {
throw firstError
}
}
} catch (err) {
parentPort?.postMessage({ type: 'error', error: String(err) })
@@ -294,12 +256,17 @@ function handleStop(): void {
parentPort?.postMessage({ type: 'final', text })
}
}
resetOfflineSessionState()
}
} catch (err) {
if (!isStreaming) {
resetOfflineSessionState()
}
parentPort?.postMessage({ type: 'error', error: String(err) })
} finally {
// Why: stopDictation waits on this signal; recovery must never prevent it.
parentPort?.postMessage({ type: 'stopped' })
}
parentPort?.postMessage({ type: 'stopped' })
}
function handleTeardown(): void {

Some files were not shown because too many files have changed in this diff Show More