@@ -231,7 +231,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
---
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index c8c4c18e596..71689cc0ceb 100644
--- a/docs/readme/README.fr.md
+++ b/docs/readme/README.fr.md
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
-[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -239,7 +239,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android :** [Télécharger l'APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
+- **Android :** [Télécharger l'APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index ed37efa595f..df14248b9f7 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -231,7 +231,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 77cbb42154e..e984ceffa30 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -234,7 +234,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK 0.0.50 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
+- **Android:** [APK 0.0.52 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index baa17f95955..3e3d0e6b1a5 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
-[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -234,7 +234,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Baixar APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
+- **Android:** [Baixar APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 6da8f2c38ed..8e91e70d8f6 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
@@ -231,7 +231,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
---
diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md
index 8130b770dd9..58cef59dde5 100644
--- a/docs/reference/ci-runner-efficiency.md
+++ b/docs/reference/ci-runner-efficiency.md
@@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The
estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are
baseline observations; post-merge savings have not yet been measured.
+## October 4 clock, byte and import test fixtures
+
+These changes retain production behavior, original case names and platform
+outcomes. The paired pilots use three alternating one-worker Node 24 invocations
+on Ubuntu 24 ARM. Every median below is a complete focused test invocation;
+they do not establish whole-shard savings or queue-delay improvements.
+
+| Workload | Baseline median | Candidate median | Reduction | Hosted evidence |
+| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ |
+| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) |
+| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) |
+| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) |
+| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) |
+| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) |
+| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) |
+| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) |
+| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) |
+| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) |
+| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) |
+
+Provider tests wait for the real fake-child write/ready barrier and drain the
+host stream before installing a scoped parent clock. The original 2.5/5-second
+Codex and 3-second Claude deadlines remain; before/at assertions check their
+boundaries. Early rejection and refusal resolve without waiting on an unreachable
+barrier; finally and suite teardown restore clocks and spies even after a native
+Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and
+removed teardown are caught. Missing-child failure retains a real ten-second observation window.
+Six faults for early/late stop deadlines, late queued resend and missing child
+output fail the intended assertions. Native-chat tests still use the real React
+outbox hooks. Their scoped clock retains probe, retry, churn and target-switch
+windows, drains async act work, and unmounts before clock restoration. All eight
+hook faults fail; two boundary faults pass the old coarse tests and fail the new
+before/at assertions. Node/web typecheck, lint and formatting pass.
+
+Native Buffer.equals replaces deep per-byte assertion traversal. It checks the
+complete original bytes and length; fixtures, SQLite operations, encryption and
+processes are unchanged. The six profile suites retain 114 passes and four
+existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven
+profile last-byte/length faults and two encrypted-vault last-byte/length faults
+fail their exact byte assertions. All six encrypted-vault cases still exercise
+52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and
+private permissions.
+
+The old SSH fixture created 15,197 short stage paths but never exceeded the real
+1,048,576 UTF-16-character transport tail cap; its two valid entries also left
+the 64-result assertion vacuous. The replacement uses about 1,300 real excluded
+stage directories under long Unicode path components, a real shell channel and
+the production execCommand limiter. Actual find output exceeds that cap, while
+the generated filter retains both original valid entries. A separate population
+of 65 valid directories proves the first-64 limit and native find ordering. File,
+symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes
+match across treatments (23 passes and four unavailable PowerShell 5.1 skips on
+the hosted image). Eight cap, ordering, filtering and failure faults are caught.
+Paths use platform utilities; local PowerShell availability retains its original
+skip policy. The deadline and SSH fixtures reuse existing process/stream code.
+OpenCode subscription tests batch only uninterrupted fixture writes between the
+original observation barriers. Both SQLite schema versions keep every row, rowid,
+read cap, poll, clock position and case. No durability pragma or production code
+changes. All 28 cases pass in every pair. Separate captures compare ordered
+schema and rows, transaction state, pragmas, signals, page requests/results and
+subscriber callbacks: 124,754,101 payload bytes match, canonical digest
+`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`.
+Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and
+missing rollback each fail the intended case in both schemas. Positive rollback
+controls pass. The unmeasured full-suite timing report ranked this fixture at
+134.229 seconds; the paired 47.347-second figure above is the relevant focused
+baseline, and the two figures must not be mixed into a claimed saving.
+
+Window attachment tests mock five unrelated registrar modules using their actual
+types. The existing window ownership, reload, media permission, native file drop,
+hydration barrier and updater scheduling cases remain real. Exact store/runtime/
+window arguments and daemon registration after the PTY handler are now asserted;
+nine actual production wiring/order faults fail. The registrar implementations
+retain separate handler tests. Concrete existing stubs moved to one fixture to
+stay within the line limit. All 31 cases and statuses match across all pairs. The
+final source differs from the timed source only by a required type-assertion
+safety comment.
+OpenCode 2 TUI tests use a scoped async clock only after the first real native
+module import. The unchanged generated plugin runs against the existing fake TUI
+and fetch. Original poll, permission, retry, preview, slow POST and endpoint
+windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission,
+120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes
+match. Separate ordered captures preserve 678 TUI/event rows and 362 complete
+POST start/completion rows; wall timestamps and cross-stream interleaving are
+excluded from equivalence. Eleven generated-source faults fail their intended
+identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/
+disposer rejection and timeout controls confirm restoration of clocks, fetch,
+argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown.
+Title-send authorization tests retain real terminal creation, graph binding and
+positive evidence paths. Negative process-evidence probes use scoped clocks
+after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at
+6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions
+check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual
+early/late wrapper and guard deadlines, false spinner identity and unknown-agent
+authorization faults fail their intended assertions; native clocks and runtime
+instance spies are restored after each failure.
+Range-selection tests stub only the saved-note send menu, which their empty
+comment populations never render. A facade typed from the actual menu props
+throws if invoked, and an afterEach assertion verifies no call with unconditional
+mock clearing in finally. The real hook, Monaco constants/model, line and range
+drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15
+original test bodies are byte-unchanged. Three actual range/hunk/draft faults
+fail their original assertions; a real draft-render menu call hits the facade
+and sentinel, and an outer cleanup check proves mock state cleared after failure.
+The actual saved-note menu retains its independent component tests.
+
## October 2 headless detector compiler cache
The deferred detector already avoids dependency setup for known build inputs.
@@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command
timings exclude setup and queues; compiler variation contributes to the cold
difference. The retained arrangement showed no cold compiler penalty.
+The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below.
+
Combining static analysis too was rejected. An
[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650)
saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds
@@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings.
## Sequential static analysis and typecheck: retain separate jobs
+The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity).
+
A four-trial hosted screen kept the slim router unchanged and compared the two
independent ARM jobs with one ARM job running their unchanged checks sequentially.
The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888)
@@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch
is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR
checks qualify separately.
+## October 4 reusable cells for terminal context scans
+
+Terminal cursor-context scans now request one reusable cell per invocation when
+the adapter offers getNullCell, and pass it through all unchanged text/style
+scans. Adapters without that optional method keep the existing allocating path.
+The scratch cell is local and no cell reference escapes into returned context.
+Browser composer/readiness text, colors, bold flags and wrapping are unchanged.
+
+Three alternating one-worker ARM pairs in
+[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677)
+ran all 19 original cases from readiness census suite 2. Baseline complete
+invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were
+33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%.
+This is a focused workload measurement, not a whole-shard or queue-delay claim.
+
+Separate baseline/candidate captures retained all 192 cases across six census
+suites. Every context and visible projection matched: 643,926 of each, with
+7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order.
+The canonical capture digest was
+`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`.
+These captures run outside the timing samples. All 41 cursor/composer/browser
+consumer checks passed. Seven faults for lost dim filtering, wide continuation,
+bold prompt, custom foreground, wrap preservation, adapter fallback and scratch
+reuse failed their intended assertions. Node and web typecheck, lint and format
+passed. Two added controls prove per-call scratch lifetime and adapter parity.
+
## October 3 producer follow-up: automatic selection for the measured profile
The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927)
@@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local
verification passed eight suites / 184 tests, the changed-code quality gate and
compiled-composite actionlint.
+## October 4 shared PR preflight capacity
+
+Static analysis and the unchanged compiler now share one ARM runner and guarded
+Node 24 install. Static checks finish and all background work joins before the
+compiler starts; unit planning still overlaps compilation. Each phase keeps its
+classifier output. Successful no-op background bodies register every required
+join when a phase is unselected or an earlier step failed. Unit and package
+consumers depend on physical job success, including action cleanup.
+
+Three counterbalanced pairs in
+[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601)
+used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1,
+policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan.
+Both arms used the PR root-only download-store policy. Total active job time was
+152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the
+extra measurement-only evidence steps gives 151 / 151 / 149 versus
+136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%).
+Two heavy runner admissions become one. This saves capacity; it does not prove a
+whole-PR latency or queue gain. The median active dependency barrier increases
+from 116 to 132 seconds because compilation follows static checks.
+
+The separate physical-failure run
+[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694)
+proved that an included TypeScript error failed the actual compiler, its planner
+still joined, and unit/package admissions skipped. A registered late action post
+failure also blocked both consumers after successful foreground checks and
+published shards. All 12 unselected/prior-failure no-op backgrounds joined, and
+the downstream audit passed. Local workflow contracts passed 239 tests across
+12 suites; lint and formatting passed.
+
## October 3 retired-cache collection observation
The same owner-collection assertion failed in unit shard 3 of
@@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the
strong-retention oracle. Hosted qualification is still required; these observations
do not prove a particular VM-retention cause or quantify avoided retries.
+## October 4 terminal oracle execution
+
+Three measured test-support changes preserve the original seeds, payloads,
+chunk boundaries and meaningful assertions. Serializer comparisons reuse cells
+and format only the first mismatch instead of allocating descriptors for every
+cell. The terminal parity writer submits every original chunk in FIFO order and
+awaits the final parser callback. The independent legacy frame oracle memoizes
+measured code-point widths. Its discarded algebra-only case never called
+production and still passed when production always threw.
+
+Three alternating one-worker hosted ARM pairs measured complete invocations:
+
+| Cohort | Baseline median | Candidate median | Saving |
+| ---------------------------------------- | --------------- | ---------------- | ------ |
+| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% |
+| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% |
+| Frame equivalence | 18.472s | 13.736s | 25.6% |
+
+[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
+retained 116 timed serializer passes and three existing/paired-control skips.
+Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and
+8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`),
+including complete outputs and diagnostic payloads. Twenty candidate controls
+passed; formatting/color/blank/clipping fault controls detected regressions.
+
+[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275)
+retained all 16 parity cases and default fuzz counts. Captures matched 2,325
+batches, 28,182 original chunks and 1,698,285 input bytes, with identical
+terminal state and serialization per terminal/batch. Independent terminal
+completion order differs, so comparison uses canonical per-terminal ordering
+(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`).
+All 73 callback/parser/other-consumer controls passed; first-callback, reversed
+chunks, missing empty boundary and early-completion faults failed.
+
+The frame candidate passed all 19 retained cases directly against the original
+uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials.
+Sequence, surrogate width, byte width and span-transform faults failed real
+assertions. A part-array alternative was rejected after adding time locally.
+Hosted Node typecheck passed. These are focused workload savings, not measured
+whole-shard or queue-delay improvements; application behavior is unchanged.
+
## October 3 unit-selection evidence: include failed references
The caller's `needs.test.result == 'success'` condition prevented the advisory
@@ -1941,6 +2148,49 @@ remained red. Its review recognized all five shards as a complete reference
(10,608 files, 8,965,977 worker-ms). This was again a full fallback with
`selectionEvaluated: false`, not evidence for enabling selected tests.
+## October 4 runtime imports and recovery fixtures
+
+Three helper-only tests now import the existing terminal modules directly rather
+than initializing the runtime service. Ten copied-loop cases never exercised
+runtime memoization: they passed with its cache, timestamp update or prune
+invalidation disabled. Two actual helper checks remain. The existing runtime
+prune suite now exercises real leaf cache reuse, split prompt timestamps,
+ordinary output, fresh prompts and detection after retained-history eviction.
+Each of those three production faults fails a real runtime assertion.
+
+Recovery tests now seed three exact fixture variants once, after the seed child
+has closed. Each crash still receives an independent byte-for-byte copy of the
+entire database/WAL family and remapped paths. Buffer.equals retains exact byte
+comparison without recursive matcher overhead. All 46 original crash boundaries
+and retries remain. Four additional copy-isolation/WAL checks run, and teardown
+requires that all seed bytes remain unchanged after the full suite.
+
+Three alternating one-worker hosted ARM pairs in
+[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
+measured these complete invocations:
+
+| Cohort | Baseline seconds | Candidate seconds | Median saving |
+| --------------------------------- | ------------------------ | ------------------------ | ------------- |
+| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
+| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
+| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
+
+The final runtime cohort has seven real cases versus 16 including the copied
+loops; its new runtime case is included in candidate timing. Recovery has 50
+passes versus the original 46. Hosted Node typecheck passed. Recovery faults for
+last-byte database/WAL corruption, shared database paths, missing WAL copies and
+accepted/unaccepted seed collision failed the intended assertions. These are
+focused workload savings, not measured whole-shard or queue-delay gains.
+
+An independent local cache screen left both caches disabled. Across 14 unchanged
+files and 92 cases, a warm Vitest transform cache reduced median invocation time
+3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to
+3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale
+transforms after TypeScript configuration or plugin-option changes, so persisted
+reuse requires a complete transform-input stamp and hosted net-cost evidence.
+A separate 130,000-pane leaf-collection optimization was restored: its complete
+migration-file timing stayed within noise. The regression fixture remains.
+
## October 3 removal fixture cleanup ordering
[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358)
@@ -1959,3 +2209,177 @@ same ordering assertion. The gate was released, both controls drained the captur
job, and the instrumentation was removed. Changed-code quality passed. This proves
the teardown ordering mechanism, not a measured avoided-retry saving. Final-head
hosted qualification remains required.
+
+## October 4 store oracle and retention fixtures
+
+The randomized in-place-store test validated the copying oracle twice after
+accepted mutations and compared snapshots through the same production parser.
+Its 5,000-step retention fixture generated enough tombstones to hit the count
+limit, but never reached the 4,096-revision age boundary.
+
+The test retains all four seeds and 1,500 mutations per seed, removes the duplicate
+validation, and projects snapshots directly from the copying oracle's validated
+maps. Separate fixtures now check the revision before, at and after expiry and
+count overflow. Production code is unchanged.
+
+Three alternating one-worker pairs on `ubuntu-24.04-arm` in
+[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
+measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate
+13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681
+seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a
+focused test saving, not a measured whole-shard or queue-delay change.
+
+Hosted Node typecheck passed. Separate fault controls failed the intended
+assertion for early, late and disabled age expiry, disabled count compaction,
+and a snapshot that drops child descriptions. The description fault passes with
+the original parser-sharing oracle and fails with the independent projection.
+
+## October 4 Git contention and remaining readiness waits
+
+The full Git admission benchmark compared a disabled arm with no correctness
+assertions to an enabled arm with structural ledger checks. Its default CI test
+now saturates the real base and headroom budgets with FIFO-gated child processes,
+queues older background and newer interactive work, releases base slots, and
+requires interactive priority, matching outputs and complete permit release.
+The full original diagnostic remains opt-in through
+`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally.
+The existing Windows real-Git parity tests remain unchanged; this fixture retains
+its existing POSIX platform scope.
+
+Two remaining Antigravity transcript tests used real 5,000ms refusal windows.
+They now use the existing scoped `waitForTranscriptIdle` timer harness after the
+emulator drains. All 60 tests, original captured transcripts, deadlines and
+readiness assertions remain.
+
+Three alternating one-worker hosted ARM pairs in
+[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492)
+measured these complete focused invocations:
+
+| Suite | Baseline seconds | Candidate seconds | Median saving |
+| --------------------- | ------------------------ | ------------------------ | --------------- |
+| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
+| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) |
+
+Each candidate passed its original meaningful checks. Hosted Node typecheck
+passed. Separate scheduler faults for bypassed admission, withheld release and
+FIFO-only priority failed the queued-contention or interactive-start assertion.
+Two additional local transcript faults failed the original picker-rejection and
+repaint-readiness assertions. These are focused suite savings; whole-shard time
+and queue delay were not measured by this experiment.
+
+## October 4 aggregate unit-test comparison
+
+A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399)
+measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second
+reduction in the slowest shard (5.98%). It compares the accepted optimizations
+with their original file snapshots on the same source, five fixed shard
+assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired
+trial, not a population estimate or a measurement of PR queue delay.
+
+| Test process | Original snapshots | Accepted optimizations |
+| ----------------------------- | ------------------ | ---------------------- |
+| Shard 1 | 574.221s | 533.116s |
+| Shard 2 | 572.553s | 569.593s |
+| Shard 3 | 630.629s | 592.935s |
+| Shard 4 | 565.412s | 546.759s |
+| Shard 5 | 609.410s | 581.218s |
+| Sum: runner time during tests | 2952.225s | 2823.621s |
+| Maximum: test critical path | 630.629s | 592.935s |
+
+Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree
+`4d5487e8b827`. One added eight-case batching qualification passes in a separate
+0.770-second invocation outside the table, completing the 10,839-module ordinary
+census. The complete timed case and outcome
+comparison accounts for eight approved coverage changes: 105,231 original cases
+versus 105,242 candidate cases. Removed copied simulations and an algebra-only
+case are accompanied by real runtime, retention, recovery and reusable-cell
+regressions. No unexpected case or outcome difference is accepted.
+
+Each arm starts with distinct empty transform and result caches; Node compilation
+caching is disabled. Cold-cache execution ordering remains Vitest's default and
+can change with source size. Source snapshots, assignments, raw reports and case outcomes
+are checked. Only three case-title fields containing random temporary paths or
+a UUID use stable identities, bound to the exact two test-source hashes; raw
+titles remain in the artifacts.
+
+The five dependency setups total 84.284 seconds and are shared by both arms.
+The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the
+first start to the last completion. Those job figures include both treatments,
+setup, uploads and staggered starts; they cannot be assigned to either arm or
+used as a workflow saving. The table measures test-process wall time, not CPU
+time or the complete CI workflow. Focused-suite percentages elsewhere in this
+report are separate measurements and must not be summed into these results.
+
+The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646)
+is rejected because a real test failed; its timings do not qualify a gain. Two
+local invocations sharing one XDG directory reproduced the Muse refresher failure.
+The fixture now isolates and restores that setting in both arms. The historical
+serializer case ledger was also independently corrected from the original source
+before this fresh trial; its seven original cases and twenty candidate cases are
+an explicit coverage change rather than an assumed equal census.
+
+## October 4 shard-weight holdouts
+
+Fresh shard weights were generated with the production importer from a complete
+successful run of the accepted source. Two subsequent hosted holdouts used those
+same weights and assignments without retraining. The
+[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated
+existing and fresh assignments across the five jobs; the
+[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed
+each job's treatment order.
+
+| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh |
+| ------------------------ | --------------- | ------------ | ------------------ | --------------- |
+| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s |
+| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s |
+
+Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased
+the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with
+a repeated critical-path regression, so the existing weights remain. The 3.01%
+improvement projected from training module durations is not a measured speed
+gain.
+
+Every arm covers the same 10,839 modules and 105,250 case outcomes on source
+`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers.
+Both trials use cold caches and the same training data, weights, plans and case
+identity rules. Complete raw reports, assignments, hashes and opposite treatment
+orders are checked before combining the results. Two pairs supply no statistical
+confidence or account-wide queue measurement. The second run's jobs started 119
+seconds apart; that stagger and the paired jobs' setup and upload costs are
+separate from the treatment timings above.
+
+## October 4 remaining unit-test opportunities
+
+The audit retained real child-process, PTY, SSH and crash-boundary tests. It
+removed copied simulations or algebra-only cases after fault controls showed
+that they could pass with production behavior broken. The retained or replacement
+tests exercise production behavior directly. The focused timings in this report
+use the final qualified checks. They must not be added together to estimate a
+whole-workflow saving.
+
+Several further changes did not justify promotion:
+
+- A synchronous readiness-clock screen retained all 49 shard cases and their
+ outcomes, but complete invocation time changed only from 34.210 to 33.518
+ seconds in one local pair. That 2% result was too small to ship without a
+ stronger result; the original implementation remains.
+- A larger local transform-cache screen reduced warm test execution. Separately
+ measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and
+ archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds
+ with caching disabled. This component estimate excludes transfer costs; it is not an
+ end-to-end measurement. Unkeyed plugin options, inherited configuration and
+ import priority also produced stale reuse. Persisted test transforms remain
+ disabled.
+- Two successful full-run shadow references identified about 1.9% of
+ recorded worker time as omittable. That is advisory worker time, not measured
+ runner occupancy. It does not supply the failed-reference evidence or a
+ complete selected-run comparison needed to enable test selection.
+- Six sampled failed PR runs contained no failed unit job that could trigger
+ unit-matrix fail-fast. Successful unit siblings of failures in other jobs
+ cannot be counted as savings from that policy. The sample is too small to
+ establish a population-wide rate, and the policy remains unchanged.
+
+These screens reject the examined changes; they do not establish that every
+future optimization is exhausted. Shorter admitted jobs and one shared preflight
+reduce demand on the existing runner allowance. They do not increase that
+allowance or prove lower queue delay under different account traffic.
diff --git a/docs/reference/deepseek-build-observation.md b/docs/reference/deepseek-build-observation.md
new file mode 100644
index 00000000000..50686cf308d
--- /dev/null
+++ b/docs/reference/deepseek-build-observation.md
@@ -0,0 +1,28 @@
+# DeepSeek Build terminal identity
+
+DeepSeek Build is the third-party [`innocarpe/deepseek-build`](https://github.com/innocarpe/deepseek-build) product, published as `@innocarpe/deepseek-build`. It is distinct from official DeepSeek Harness (`@deepseek-ai/dsh`), Reasonix, DSH Console and generic DeepSeek TUI wrappers.
+
+Orca recognizes manually started Build terminals through its existing process and title observations. `TerminalAgent` includes `dsb`; the launchable `TuiAgent` registry does not. No Build launcher, hook, readiness profile, resume command or history reader is registered. Existing generic terminal input remains available.
+
+The source and actual macOS release were checked at **v6.9.0**, source commit `74df67a56988e9a32845c4565cc62b021ea68c7d`. The darwin-arm64 release tarball SHA-256 is `a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874`.
+
+- `package.json` publishes `dsb.js` and `deepseek-build.js` npm shims; the native child is `deepseek-build-agent`.
+- `crates/dsb-cli/src/main.rs` separates the full-screen entry from `run`. Global value options such as `--cwd` can precede `run`; those invocations remain excluded from interactive recognition.
+- `crates/dsb-cli/src/agent_launch.rs` emits the product OSC 0 title. The vendored pager's `notifications/title.rs` composes spinner, activity and product segments with ` - ` separators.
+- The committed `dsb-6-9-0-folder` PTY fixture records the released binary's welcome screen and actual title in an isolated home and plain folder. It makes no successful-authentication or completed-model-turn claim. Its runtime test feeds raw chunks through `onPtyData` with foreground inspection unavailable.
+
+Explicit native owner markers retain their existing precedence. A Claude task merely mentioning Build is not a Build identity. Runtime publication reuses the existing optional `agentIdentity` string; no new RPC, stream opcode or status producer is added. Older hosts can omit identity, while older readers retain their existing unknown-agent handling. Execution-host process/title observations work without a Git repository; local source tests do not establish native Windows, Linux or SSH device coverage.
+
+For rendered proof, isolate both Electron and the actual PTY. On macOS, `login(1)` can replace the shell's inherited home. Test-only `ORCA_DISABLE_MACOS_LOGIN_SHELL=1` avoids that wrapper; do not change production launch policy for a proof. Require a nonce-bound file written by a helper executed in the spawned PTY, containing its actual `HOME`, `USERPROFILE`, `DEEPSEEK_BUILD_HOME`, `GROK_HOME` and trust-RPC flag, and verify it before agent launch. A terminal-text assertion can match command echo and is not isolation evidence. Explicit provider environment at the final execution boundary protects the test even after shell startup.
+
+The observation-type propagation and title/process recognition adapt Wooseong Kim's (`innocarpe`) [PR #23485](https://github.com/stablyai/orca/pull/23485), with source-backed corrections for the second npm shim and value options before `run`. Keep that predecessor open until a reviewed successor merges.
+
+Independent review follow-up: upstream 6.9.0 outer `Commands::Agent` forwards native PagerArgs options. Native `-p`/`--single` (alias `--print`), `--prompt-json` and `--prompt-file` are one-shot forms and are excluded from interactive process/foreground identity, including equals/compact short forms, npm wrappers and preceding value options. Positional interactive prompt text, native option values and the native `--` terminator stay distinct. Actual release native `--help` confirms exposed flags; source alias and forwarding are pinned above.
+
+Title follow-up confines Gemini identity/normalization and status sniffing before inspecting Build activity text. A verified Build title uses its leading own braille frame for working and leading `⚠ Action Required - ` for permission; embedded Gemini glyphs in activity/session/cwd text do not change its identity or status. Source-backed frame tests cover wrapped and alert variants, plus OSC input through the actual runtime/listing path. Native Gemini and other provider corpus contracts stay covered.
+
+Actual released outer `dsb agent -- --help` prints the native TUI help (`outer-forwarded-help.txt`), confirming Clap consumes the outer separator before forwarding. The observer distinguishes this from the native `--`: `dsb agent -- --print task` is one-shot, whereas `dsb agent -- -- --print` and direct native `-- --print` retain literal interactive prompt text.
+
+Native grammar follow-up: only the outer wrapper's `run` subcommand is one-shot. Native `deepseek-build-agent run`, forwarded `dsb agent run`, and `--leader-socket run` remain interactive; the last consumes `run` as a path value. Native `-c` is boolean, so Clap accepts `-cp task` and `-cptask` as continue plus single-turn prompt. Attached `-m`/`-r`/`-s`/`-w` values (including after `c`) do not expose a prompt flag. The released binary accepted the five review argument topologies with `--help` under an executed private-child environment assertion (`native-grammar-oracle.json`); this proves parsing/help, not successful model generation.
+
+Attached prompt values can begin with hyphens: native `-p-` and `-cp--print` consume `-` and `--print` as the single-turn prompt. The observer accepts the entire remainder after `p`, while the attached m/r/s/w value shields stay covered. The released native binary and outer `agent` wrapper accepted both forms with `--help` in a nonce-asserted private child (`attached-p-oracle.json`).
diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md
deleted file mode 100644
index 5eaf0d52463..00000000000
--- a/docs/reference/jcode-hook-events.md
+++ /dev/null
@@ -1,163 +0,0 @@
-# jcode hook events
-
-What jcode actually emits, and why Orca's status mapping is shaped the way it is.
-Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every
-`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to
-a log, then running real turns. Re-capture before changing the mapping; do not
-edit it from memory.
-
-## The six events
-
-jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle
-points. Five are **observers** — detached, fire-and-forget, they can never slow
-the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input
-to its stdin, and waits for it to exit before the tool runs.
-
-| Event | When | Orca state | Notable payload fields |
-| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- |
-| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` |
-| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` |
-| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) |
-| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` |
-| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` |
-| `session_end` | session closed | `done` | `source` = `close` |
-
-`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping
-it to `working` would spin before the user has typed anything (same reason Devin
-does not map its `SessionStart`).
-
-## Captured payloads
-
-A `jcode run` turn that read one file and wrote another:
-
-```json
-{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"}
-{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"}
-{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"}
-{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"}
-{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"}
-```
-
-A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit):
-
-```json
-{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"}
-{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"}
-{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"}
-```
-
-Three consequences the mapping depends on:
-
-- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm
- workers, headless sessions), not `jcode run`. It is what fills the otherwise
- blank window between a submitted prompt and the first tool call.
-- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the
- outcome, so the tool preview has to be held from the matching `pre_tool`.
-- **Every jcode tool schema has an `intent` string** the model fills in. It is
- the preview fallback when no tool-specific key (`file_path`, `command`, …)
- matches.
-
-## Why Orca subscribes to the gate
-
-`pre_tool` is the only event that can report a tool *while it runs*. Without it a
-three-minute `bash` shows no tool at all until it finishes. Two rules keep the
-gate from ever costing the agent anything:
-
-1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits
- for the process *and* reads its stderr to EOF — a backgrounded child that
- inherited stderr would hold the gate open for as long as it ran. The managed
- script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the
- inherited pipes are closed and the script exits immediately.
-2. **stdin is drained first.** jcode `write_all`s the full tool input to the
- hook's stdin. A tool input larger than the pipe buffer (a big `write`) would
- block that write until the gate timed out if nobody read it, so the script
- drains stdin before any exit path.
-
-Orca never blocks a jcode tool call: the script always exits 0.
-
-## Questions and permissions
-
-jcode has **no interactive per-tool approval prompt**. Its safety model
-(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a
-command outright or asks the model to justify it — both inside the tool, with no
-human in the loop. There is therefore no hook, and no terminal-title state, for
-"jcode is waiting on you" during ordinary tool use.
-
-The one tool a *human* answers is ambient mode's `request_permission`
-(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with
-`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the
-tool input as the question card. `post_tool` for the same tool is *not* mapped —
-by then the human has already answered.
-
-Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch,
-bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail,
-integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory,
-multiedit, open, panel, patch, read, schedule, session_search, side_panel,
-skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools;
-a substring rule over that set would be matching on coincidence.
-
-## Terminal titles
-
-jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI
-session:
-
-```
-jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s
-```
-
-The format is ` jcode [ · +N -M][ · work|last ~]`
-(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar
-identity only — status comes from hooks, never from a parsed title. Note there is
-no "needs input" title state; that is the same gap as above, not an omission in
-the parser.
-
-## Per-pane daemons
-
-jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire
-*inside the daemon*. Every TUI client connects the daemon the first pane started,
-so without isolation a second jcode pane's events carry the first pane's
-`ORCA_PANE_KEY` and its status lands on the wrong tab.
-
-jcode does forward a client's terminal identity to hooks
-(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but
-that allowlist covers tmux/zellij/herdr and the terminal emulators — not
-`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so
-each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of
-the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a
-full pane key never fits.
-
-## Windows hook launcher
-
-Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with
-`DETACHED_PROCESS`, leaving their children without a console to inherit. A
-console program such as the managed hook's `curl.exe` can then open a Windows
-Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for
-observer hooks and the `pre_tool` gate, keeping their descendants invisible.
-Changing the managed script alone cannot repair an older Jcode launcher.
-
-The managed Windows hook redirects its payload file into curl directly, avoiding
-the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts
-are refreshed on Orca startup without changing the user's hook configuration.
-
-Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574
-Launcher fix: https://github.com/1jehuang/jcode/pull/1490
-
-## Config shape
-
-`[hooks]` values accept a string or an array of strings (`HookCommands` in
-`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload,
-so hooks can be added without restarting. jcode parses a hook command line
-shell-style but **executes it directly, not through a shell** — the managed value
-must be the script path, never an `if [ -f … ]` wrapper.
-
-That shell-style parse is `parse_hook_command`
-(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path
-**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every
-unquoted backslash as an escape, so a bare Windows path reaches `exec` as
-`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home
-with a space splits into two arguments. Single quotes pass a path through
-verbatim — backslashes are literal inside them — so Orca single-quotes by
-default and falls back to double quotes (escaping `\` and `"`) only for a path
-that itself contains a single quote. The value is then TOML-quoted on the way
-into the file, so neither the raw path nor the shell-quoted string appears
-alone.
diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md
index b2bcb3c40f4..c7720c68dce 100644
--- a/docs/reference/remote-wire-compatibility.md
+++ b/docs/reference/remote-wire-compatibility.md
@@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own.
reply-schema fallback must never shape a param. Gate on the token instead, where the
client decides what it is willing to do with an arm it does not know.
+## Session search agent negotiation
+
+`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients
+send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists,
+so a future provider name does not make a peer reject the capability reply. The client
+narrows explicit agent filters to the host's list before calling its request parser.
+The host narrows retrieval to the client's list before publishing a page.
+
+A peer without this field uses the frozen v1.4.211 search vocabulary. The existing
+`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support;
+`supportsJcodeHistory` independently proves Jcode support. An explicit list takes
+precedence over both flags. If only the status method is missing, the client still
+searches the conservative legacy subset; other status errors propagate. Empty host
+intersections keep the requested filters and use the existing no-match scope, so
+consent, readiness, and unknown-scope results retain their normal precedence.
+Local IPC advertises this build's full list, and every remote leg negotiates separately.
+
## Enforcement
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx
index c90cf451351..52e432c4b6c 100644
--- a/docs/site/content/docs/model/worktrees.mdx
+++ b/docs/site/content/docs/model/worktrees.mdx
@@ -114,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi
When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded.
+A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none.
+
Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)).
## Resource Manager cleanup
diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx
index 1bf6dc79434..4fac0564d48 100644
--- a/docs/site/content/docs/settings.mdx
+++ b/docs/site/content/docs/settings.mdx
@@ -133,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
- Full keymap — every binding remappable.
- Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter.
+- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered.
- **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work.
- Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux.
- **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`.
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index a899fd97f9f..ac23e12afba 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -13,7 +13,8 @@ import {
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set([
- '@streamparser/json',
+ 'stream-json',
+ 'stream-chain',
'@xterm/headless',
'@xterm/addon-serialize',
'tldts',
diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json
index 8784dc31168..d4adb3e24fd 100644
--- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json
+++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json
@@ -298,8 +298,8 @@
{
"id": "inventory-lifecycle.unmount-before-1:settled",
"observation": {
- "sender": ["32c9018052ba", "2f1953cc0315"],
- "payloads": ["8db0d1234c94", "c9b9d7a9b388"],
+ "sender": ["32c9018052ba"],
+ "payloads": ["8db0d1234c94"],
"settlements": {
"mount": "eb79a9b3682a",
"old": "eb79a9b3682a",
@@ -312,8 +312,8 @@
{
"id": "inventory-lifecycle.unmount-before-1:remounted",
"observation": {
- "sender": ["32c9018052ba", "2f1953cc0315"],
- "payloads": ["8db0d1234c94", "c9b9d7a9b388"],
+ "sender": ["32c9018052ba"],
+ "payloads": ["8db0d1234c94"],
"settlements": {
"mount": "eb79a9b3682a",
"old": "eb79a9b3682a",
diff --git a/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts
new file mode 100644
index 00000000000..1f30232828f
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts
@@ -0,0 +1,217 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge/bridge-caps'
+import { createFakeRpcClient } from './bridge-host-test-fakes'
+import { harness, ID, subscribeFrame } from './bridge-host-test-harness'
+import { BRIDGE_MAX_UNACKED_FRAMES, BridgeHostSubscriptions } from './bridge-host-subscriptions'
+import {
+ BridgeTerminalOutputBacklog,
+ TERMINAL_STREAM_ACK_SILENCE_MS,
+ type TerminalBacklogTimers
+} from './bridge-terminal-output-backlog'
+
+const HELD_OUTPUT = { type: 'data', streamId: 1, chunk: 'h'.repeat(48 * 1024) }
+
+function manualTimers(): TerminalBacklogTimers & {
+ pending: () => number
+ delays: number[]
+ fire: () => void
+} {
+ const handlers = new Map void>()
+ const delays: number[] = []
+ let nextHandle = 0
+ return {
+ set: (handler, ms) => {
+ nextHandle += 1
+ handlers.set(nextHandle, handler)
+ delays.push(ms)
+ return nextHandle
+ },
+ clear: (handle) => {
+ handlers.delete(handle)
+ },
+ pending: () => handlers.size,
+ delays,
+ fire: () => {
+ for (const [handle, handler] of handlers) {
+ handlers.delete(handle)
+ handler()
+ }
+ }
+ }
+}
+
+function emitWindowAndHold(onData: (payload: unknown) => void): void {
+ for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES; index += 1) {
+ onData({ type: 'data', streamId: 1, chunk: String(index) })
+ }
+ onData(HELD_OUTPUT)
+}
+
+function observeBacklogs(): BridgeTerminalOutputBacklog[] {
+ const backlogs: BridgeTerminalOutputBacklog[] = []
+ const hold = BridgeTerminalOutputBacklog.prototype.hold
+ vi.spyOn(BridgeTerminalOutputBacklog.prototype, 'hold').mockImplementation(function (
+ this: BridgeTerminalOutputBacklog,
+ payload: unknown
+ ) {
+ if (!backlogs.includes(this)) {
+ backlogs.push(this)
+ }
+ return hold.call(this, payload)
+ })
+ return backlogs
+}
+
+afterEach(() => vi.restoreAllMocks())
+
+describe('bridge subscription start cleanup', () => {
+ it('rethrows the original immediate failure without keeping a slot or timer', () => {
+ const failure = new Error('subscribe failed')
+ const timers = manualTimers()
+ const subscriptions = new BridgeHostSubscriptions({
+ client: {
+ ...createFakeRpcClient(),
+ subscribe: () => {
+ throw failure
+ }
+ },
+ post: vi.fn(),
+ onBinaryFrameDropped: vi.fn(),
+ terminalTimers: timers
+ })
+
+ expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure)
+ expect(subscriptions.size).toBe(0)
+ expect(timers.pending()).toBe(0)
+ expect(timers.delays).toEqual([])
+ })
+
+ it('releases held output and its silence timer when subscribe emits and then throws', () => {
+ const failure = new Error('subscribe failed after output')
+ const timers = manualTimers()
+ const backlogs = observeBacklogs()
+ const subscriptions = new BridgeHostSubscriptions({
+ client: {
+ ...createFakeRpcClient(),
+ subscribe: (_method, _params, onData) => {
+ emitWindowAndHold(onData)
+ expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length)
+ expect(timers.pending()).toBe(1)
+ throw failure
+ }
+ },
+ post: vi.fn(),
+ onBinaryFrameDropped: vi.fn(),
+ terminalTimers: timers
+ })
+
+ let thrown: unknown
+ try {
+ subscriptions.start(ID, 'terminal.subscribe', {})
+ } catch (error) {
+ thrown = error
+ }
+ expect(thrown).toBe(failure)
+ expect(subscriptions.size).toBe(0)
+ expect(backlogs).toHaveLength(1)
+ expect({
+ pendingBytes: backlogs[0]?.pendingBytes,
+ held: backlogs[0]?.held,
+ liveTimers: timers.pending()
+ }).toEqual({ pendingBytes: 0, held: false, liveTimers: 0 })
+ expect(timers.delays).toEqual([TERMINAL_STREAM_ACK_SILENCE_MS])
+ })
+
+ it('answers the original failure and leaves a same-ID replacement alive past the old deadline', () => {
+ const client = createFakeRpcClient()
+ const timers = manualTimers()
+ let starts = 0
+ const bridge = harness({
+ ready: true,
+ terminalTimers: timers,
+ client: {
+ ...client,
+ subscribe: (method, params, onData, options) => {
+ starts += 1
+ if (starts === 1) {
+ emitWindowAndHold(onData)
+ throw new Error('failed after output')
+ }
+ return client.subscribe(method, params, onData, options)
+ }
+ }
+ })
+
+ bridge.host.receive(subscribeFrame(ID))
+ expect(bridge.last()).toMatchObject({ type: 'error', id: ID })
+ bridge.host.receive(subscribeFrame(ID))
+ expect(client.streams).toHaveLength(1)
+ timers.fire()
+ expect(client.streams[0]?.unsubscribes).toBe(0)
+ client.streams[0]?.emit({ type: 'data', streamId: 2, chunk: 'replacement still live' })
+ expect(bridge.last()).toEqual({
+ v: 1,
+ type: 'event',
+ id: ID,
+ seq: 1,
+ payload: { type: 'data', streamId: 2, chunk: 'replacement still live' }
+ })
+ expect(bridge.frames().filter((frame) => frame.type === 'end')).toEqual([])
+ bridge.host.dispose()
+ expect(client.streams[0]?.unsubscribes).toBe(1)
+ })
+
+ it('keeps a successful synchronous backlog until ack and unsubscribes once on cancel', () => {
+ const timers = manualTimers()
+ const backlogs = observeBacklogs()
+ const unsubscribe = vi.fn()
+ const subscriptions = new BridgeHostSubscriptions({
+ client: {
+ ...createFakeRpcClient(),
+ subscribe: (_method, _params, onData) => {
+ emitWindowAndHold(onData)
+ return unsubscribe
+ }
+ },
+ post: vi.fn(),
+ onBinaryFrameDropped: vi.fn(),
+ terminalTimers: timers
+ })
+
+ subscriptions.start(ID, 'terminal.subscribe', {})
+ expect(subscriptions.has(ID)).toBe(true)
+ expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length)
+ expect(timers.pending()).toBe(1)
+ subscriptions.ack(ID, BRIDGE_MAX_UNACKED_FRAMES)
+ expect(backlogs[0]?.pendingBytes).toBe(0)
+ expect(timers.pending()).toBe(0)
+ subscriptions.cancel(ID, null)
+ subscriptions.cancel(ID, null)
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ it('preserves an overflow emitted before the original subscribe failure', () => {
+ const failure = new Error('failure after overflow')
+ const timers = manualTimers()
+ const post = vi.fn()
+ const subscriptions = new BridgeHostSubscriptions({
+ client: {
+ ...createFakeRpcClient(),
+ subscribe: (_method, _params, onData) => {
+ onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES))
+ throw failure
+ }
+ },
+ post,
+ onBinaryFrameDropped: vi.fn(),
+ terminalTimers: timers
+ })
+
+ expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure)
+ expect(post.mock.calls).toEqual([
+ [JSON.stringify({ v: 1, type: 'end', id: ID, reason: 'overflow' })]
+ ])
+ expect(subscriptions.size).toBe(0)
+ expect(timers.pending()).toBe(0)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
index ab5430f15f6..e6f6847e6d9 100644
--- a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
@@ -143,6 +143,7 @@ export class BridgeHostSubscriptions {
)
} catch (error) {
this.open.delete(id)
+ record.backlog?.dispose()
throw error
}
// A stream that emitted and overflowed inside `subscribe` is already retired, and its
diff --git a/mobile/src/platform/native-media-device.ts b/mobile/src/platform/native-media-device.ts
index 23002b8901d..4d4c3552538 100644
--- a/mobile/src/platform/native-media-device.ts
+++ b/mobile/src/platform/native-media-device.ts
@@ -33,6 +33,14 @@ function stagedMediaFile(extension: string): FsFile {
return new FsFile(Paths.cache, `orca-media-${Date.now()}-${Math.random()}.${extension}`)
}
+function discardUnreturnedMediaFile(file: FsFile): void {
+ try {
+ file.delete()
+ } catch {
+ // Best effort; the cache is the OS's to reclaim.
+ }
+}
+
/**
* Copies what a uri names into this shell's cache and answers the copy's uri.
*
@@ -57,11 +65,7 @@ export function copyPickedMediaIntoCache(uri: string): string {
destination.write(new FsFile(uri).bytesSync())
} catch (error) {
// The empty file this just created is nobody's otherwise: the caller never learns its name.
- try {
- destination.delete()
- } catch {
- // Best effort; the cache is the OS's to reclaim.
- }
+ discardUnreturnedMediaFile(destination)
throw error
}
return destination.uri
@@ -94,7 +98,12 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi
stageBase64: (base64) => {
const file = stagedMediaFile('png')
file.create({ overwrite: true })
- file.write(base64, { encoding: 'base64' })
+ try {
+ file.write(base64, { encoding: 'base64' })
+ } catch (error) {
+ discardUnreturnedMediaFile(file)
+ throw error
+ }
return file.uri
},
openFile: (uri) => new FsFile(uri),
diff --git a/mobile/src/platform/native-media-stage-cleanup.test.ts b/mobile/src/platform/native-media-stage-cleanup.test.ts
new file mode 100644
index 00000000000..2391954c187
--- /dev/null
+++ b/mobile/src/platform/native-media-stage-cleanup.test.ts
@@ -0,0 +1,204 @@
+import { beforeEach, afterEach, expect, it, vi } from 'vitest'
+
+type FileEvent = {
+ operation: 'create' | 'write' | 'delete'
+ uri: string
+ content?: string | Uint8Array
+ options?: { overwrite?: boolean; encoding?: string }
+}
+
+type StorageState = {
+ files: Map
+ events: FileEvent[]
+ writeFailure: unknown
+ deleteFailure: unknown
+ createFailure: unknown
+ readFailure: unknown
+ partialBytes: number
+}
+
+const storage = vi.hoisted(() => ({
+ files: new Map(),
+ events: [],
+ writeFailure: null,
+ deleteFailure: null,
+ createFailure: null,
+ readFailure: null,
+ partialBytes: 0
+}))
+
+vi.mock('expo-clipboard', () => ({
+ getImageAsync: () => Promise.resolve({ data: 'data:image/png;base64,AQID' })
+}))
+vi.mock('expo-document-picker', () => ({}))
+vi.mock('expo-image-picker', () => ({}))
+vi.mock('expo-file-system', () => ({
+ File: class {
+ readonly uri: string
+
+ constructor(...parts: string[]) {
+ this.uri = parts.join('/')
+ }
+
+ get size(): number {
+ return storage.files.get(this.uri)?.byteLength ?? 0
+ }
+
+ create(options: { overwrite?: boolean }): void {
+ storage.events.push({ operation: 'create', uri: this.uri, options })
+ storage.files.set(this.uri, new Uint8Array())
+ if (storage.createFailure !== null) {
+ throw storage.createFailure
+ }
+ }
+
+ write(content: string | Uint8Array, options?: { encoding?: string }): void {
+ storage.events.push({ operation: 'write', uri: this.uri, content, options })
+ if (storage.writeFailure !== null) {
+ storage.files.set(this.uri, new Uint8Array(storage.partialBytes))
+ throw storage.writeFailure
+ }
+ storage.files.set(
+ this.uri,
+ typeof content === 'string' ? Uint8Array.from(Buffer.from(content, 'base64')) : content
+ )
+ }
+
+ bytesSync(): Uint8Array {
+ if (storage.readFailure !== null) {
+ throw storage.readFailure
+ }
+ return Uint8Array.from([9, 8])
+ }
+
+ delete(): void {
+ storage.events.push({ operation: 'delete', uri: this.uri })
+ if (storage.deleteFailure !== null) {
+ throw storage.deleteFailure
+ }
+ storage.files.delete(this.uri)
+ }
+ },
+ Paths: { cache: 'file:///cache' }
+}))
+
+import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry'
+import { createNativeMediaVerbServer } from './native-media'
+import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device'
+
+beforeEach(() => {
+ storage.files.clear()
+ storage.events.length = 0
+ storage.writeFailure = null
+ storage.deleteFailure = null
+ storage.createFailure = null
+ storage.readFailure = null
+ storage.partialBytes = 0
+ vi.spyOn(Date, 'now').mockReturnValue(1234)
+ vi.spyOn(Math, 'random').mockReturnValue(0.25)
+})
+
+afterEach(() => vi.restoreAllMocks())
+
+function device() {
+ return nativeMediaDeviceDeps(new MediaHandleRegistry({ now: () => 0, discard: vi.fn() }))
+}
+
+function thrownBy(operation: () => unknown): unknown {
+ try {
+ operation()
+ } catch (error) {
+ return error
+ }
+ throw new Error('Expected staging to throw')
+}
+
+function heldBytes(): number {
+ return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0)
+}
+
+it('preserves the successful staged URI, base64 options and bytes without deleting it', () => {
+ const uri = device().stageBase64('AQID')
+ expect(uri).toBe('file:///cache/orca-media-1234-0.25.png')
+ expect(storage.events).toEqual([
+ { operation: 'create', uri, options: { overwrite: true } },
+ { operation: 'write', uri, content: 'AQID', options: { encoding: 'base64' } }
+ ])
+ expect(storage.files.get(uri)).toEqual(Uint8Array.from([1, 2, 3]))
+})
+
+it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => {
+ const original = new Error('partial native write')
+ storage.writeFailure = original
+ storage.partialBytes = bytes
+ expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
+ expect(heldBytes()).toBe(0)
+ expect(storage.files.size).toBe(0)
+ expect(storage.events.at(-1)).toEqual({
+ operation: 'delete',
+ uri: 'file:///cache/orca-media-1234-0.25.png'
+ })
+})
+
+it('preserves the write failure when best-effort deletion also fails', () => {
+ const original = new Error('native write failed')
+ storage.writeFailure = original
+ storage.deleteFailure = new Error('cache cannot be deleted')
+ storage.partialBytes = 4096
+ expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
+ expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete'])
+ expect(heldBytes()).toBe(4096)
+})
+
+it('preserves creation failure and its original operation order', () => {
+ const original = new Error('native create failed')
+ storage.createFailure = original
+ expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
+ expect(storage.events.map((event) => event.operation)).toEqual(['create'])
+})
+
+it('deletes only the failed file while a prior successful file remains readable', () => {
+ vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2)
+ const successfulUri = device().stageBase64('AQID')
+ const original = new Error('next write failed')
+ storage.writeFailure = original
+ storage.partialBytes = 4096
+ expect(thrownBy(() => device().stageBase64('BAUG'))).toBe(original)
+ expect(storage.events.filter((event) => event.operation === 'delete')).toEqual([
+ { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.2.png' }
+ ])
+ expect([...storage.files.keys()]).toEqual([successfulUri])
+ expect(storage.files.get(successfulUri)).toEqual(Uint8Array.from([1, 2, 3]))
+ expect(heldBytes()).toBe(3)
+})
+
+it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => {
+ const original = new Error('clipboard write failed')
+ storage.writeFailure = original
+ storage.partialBytes = 4096
+ const registryDiscard = vi.fn()
+ const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard })
+ const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry))
+ await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe(
+ original
+ )
+ expect(registry.liveCount()).toBe(0)
+ expect(registryDiscard).not.toHaveBeenCalled()
+ expect(heldBytes()).toBe(0)
+ expect(storage.files.size).toBe(0)
+})
+
+it('retains the provider-copy cleanup and original read error when deletion fails', () => {
+ const original = new Error('provider read failed')
+ storage.readFailure = original
+ storage.deleteFailure = new Error('cache delete failed')
+ expect(thrownBy(() => copyPickedMediaIntoCache('content://media/1'))).toBe(original)
+ expect(storage.events).toEqual([
+ {
+ operation: 'create',
+ uri: 'file:///cache/orca-media-1234-0.25.bin',
+ options: { overwrite: true }
+ },
+ { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.25.bin' }
+ ])
+})
diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts
index 67e90132866..c8193641fef 100644
--- a/mobile/src/session/MobileNativeChatMessage.test.ts
+++ b/mobile/src/session/MobileNativeChatMessage.test.ts
@@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
+import { colors } from '../theme/mobile-theme'
vi.mock('react-native', async () => {
const React = await import('react')
@@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => {
function render(
message: NativeChatMessage,
props: {
+ fontScale?: number
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
@@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => {
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
+ it.each(['system', 'user'] as const)(
+ 'renders a %s host notice as selectable muted text rather than a markdown answer',
+ (role) => {
+ const tree = render(
+ {
+ id: 'notice',
+ role,
+ timestamp: 1,
+ blocks: [
+ { type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
+ ]
+ },
+ { fontScale: 1.5 }
+ )
+ expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
+ const text = tree.root.find((node) => String(node.type) === 'Text')
+ expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
+ expect(text.props.selectable).toBe(true)
+ expect(Object.assign({}, ...text.props.style)).toMatchObject({
+ color: colors.textMuted,
+ fontSize: 25.5
+ })
+ }
+ )
+
+ it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
+ const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
+ expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
+ 'provider fallback'
+ )
+ })
+
it('renders a loadable preview URI as an image thumbnail', () => {
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const image = tree.root.findByType('Image' as never)
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index 9c894e3cd7f..40635166281 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
+import {
+ AGENT_SESSION_HOST_STATUS_COPY,
+ isAgentSessionHostStatusPresentation
+} from '../../../src/shared/agent-session-host-status-rows'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
@@ -24,6 +28,13 @@ function Prose({
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
+ if (isAgentSessionHostStatusPresentation(block.presentation)) {
+ return (
+
+ {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
+
+ )
+ }
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
// markdown renderer's light-on-dark palette.
if (invert) {
diff --git a/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx
new file mode 100644
index 00000000000..3cecdb9ac0a
--- /dev/null
+++ b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx
@@ -0,0 +1,280 @@
+import { createElement, StrictMode, type ReactNode } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import { MobileNativeChatComposer } from './MobileNativeChatComposer'
+import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
+
+vi.mock('react-native', async () => {
+ const React = await import('react')
+ return {
+ ActivityIndicator: 'ActivityIndicator',
+ Image: 'Image',
+ Text: 'Text',
+ TextInput: 'TextInput',
+ View: 'View',
+ Pressable: 'Pressable',
+ Keyboard: { dismiss: vi.fn() },
+ ScrollView: ({ children, ...props }: { children?: ReactNode }) =>
+ React.createElement('ScrollView', props, children),
+ StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
+ }
+})
+vi.mock('lucide-react-native', () => ({
+ ArrowUp: 'ArrowUp',
+ Check: 'Check',
+ ChevronDown: 'ChevronDown',
+ ChevronLeft: 'ChevronLeft',
+ ChevronRight: 'ChevronRight',
+ ImagePlus: 'ImagePlus',
+ Mic: 'Mic',
+ Square: 'Square',
+ X: 'X'
+}))
+vi.mock('../components/BottomDrawer', async () => {
+ const React = await import('react')
+ return {
+ BottomDrawer: ({ visible, children }: { visible: boolean; children?: ReactNode }) =>
+ visible ? React.createElement('BottomDrawer', { visible }, children) : null
+ }
+})
+
+type Reply = Awaited>
+const missing: Reply = {
+ id: 'search',
+ ok: false,
+ error: { code: 'method_not_found', message: 'legacy host' },
+ _meta: { runtimeId: 'legacy' }
+}
+const inventory: Reply = {
+ id: 'list',
+ ok: true,
+ result: { files: [{ relativePath: 'src/apple.ts' }] },
+ _meta: { runtimeId: 'legacy' }
+}
+const searchCall = [
+ 'files.searchPaths',
+ {
+ worktree: 'id:folder:remote',
+ query: 'apple',
+ limit: 16
+ }
+]
+function pendingReply() {
+ let resolve: (reply: Reply) => void = () => {
+ throw new Error('uninitialized reply')
+ }
+ const promise = new Promise((settle) => {
+ resolve = settle
+ })
+ return { promise, resolve }
+}
+function mount(sendRequest: RpcClient['sendRequest'], strict = false, getGeneration = () => 1) {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual hook only reads these two RPC members at the fake transport boundary.
+ const client = { sendRequest, getGeneration } as RpcClient
+ let search: ReturnType | undefined
+ function Route({ showChat }: { showChat: boolean }) {
+ search = useMobileNativeChatFileSearch({ client, worktreeId: 'folder:remote' })
+ return showChat
+ ? createElement(MobileNativeChatComposer, {
+ value: '@apple',
+ onChangeText: vi.fn(),
+ onSend: async () => false,
+ sendSurfaceId: 'remote-chat',
+ getSendCompletionGeneration: () => 0,
+ getComposerEditGeneration: () => 0,
+ filePaths: search.nativeChatFilePaths,
+ onNeedFiles: search.loadNativeChatFiles
+ })
+ : null
+ }
+ const element = (showChat: boolean) =>
+ strict
+ ? createElement(StrictMode, null, createElement(Route, { showChat }))
+ : createElement(Route, { showChat })
+ let renderer: ReturnType | undefined
+ act(() => {
+ renderer = create(element(false))
+ })
+ if (!renderer || !search) {
+ throw new Error('actual route hook did not mount')
+ }
+ const view = renderer
+ return {
+ query: () =>
+ act(() => {
+ search?.loadNativeChatFiles('apple')
+ }),
+ showChat: () =>
+ act(() => {
+ view.update(element(true))
+ }),
+ triggerComposer: () =>
+ act(() => {
+ view.root
+ .find((node) => typeof node.props.onSelectionChange === 'function')
+ .props.onSelectionChange({ nativeEvent: { selection: { end: 6 } } })
+ }),
+ hideChat: () =>
+ act(() => {
+ view.update(element(false))
+ }),
+ paths: () => search?.nativeChatFilePaths,
+ dispose: () =>
+ act(() => {
+ view.unmount()
+ })
+ }
+}
+async function debounce(): Promise {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(120)
+ })
+}
+async function refuse(search: ReturnType): Promise {
+ await act(async () => {
+ search.resolve(missing)
+ await search.promise
+ })
+}
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+})
+
+it.each([false, true])(
+ 'a disposed actual composer owner admits no new inventory; StrictMode=%s',
+ async (strict) => {
+ const search = pendingReply()
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
+ )
+ const view = mount(send, strict)
+ view.showChat()
+ view.triggerComposer()
+ await debounce()
+ expect(send.mock.calls).toEqual([searchCall])
+ view.dispose()
+ expect(vi.getTimerCount()).toBe(0)
+ await refuse(search)
+ expect(send.mock.calls.filter(([method]) => method === 'files.searchPaths')).toHaveLength(1)
+ expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0)
+ }
+)
+
+it.each([false, true])(
+ 'removing only the composer retains live fallback and cache; StrictMode=%s',
+ async (strict) => {
+ const search = pendingReply()
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
+ )
+ const view = mount(send, strict)
+ view.showChat()
+ view.triggerComposer()
+ await debounce()
+ expect(send.mock.calls).toEqual([searchCall])
+ view.hideChat()
+ await refuse(search)
+ expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
+ expect(view.paths()).toEqual(['src/apple.ts'])
+ view.showChat()
+ view.triggerComposer()
+ await debounce()
+ expect(view.paths()).toEqual(['src/apple.ts'])
+ expect(send).toHaveBeenCalledTimes(2)
+ view.dispose()
+ }
+)
+
+it('64 retired actual hooks do not start 64 whole-workspace reads', async () => {
+ const pending = Array.from({ length: 64 }, pendingReply)
+ let index = 0
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths' ? pending[index++]!.promise : Promise.resolve(inventory)
+ )
+ for (let owner = 0; owner < pending.length; owner++) {
+ const view = mount(send)
+ view.query()
+ await debounce()
+ view.dispose()
+ }
+ expect(send.mock.calls).toEqual(pending.map(() => searchCall))
+ expect(vi.getTimerCount()).toBe(0)
+ await act(async () => {
+ pending.forEach((reply) => reply.resolve(missing))
+ await Promise.all(pending.map((reply) => reply.promise))
+ })
+ expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0)
+})
+
+it.each([false, true])(
+ 'a same-client successor owns its live fallback; StrictMode=%s',
+ async (strict) => {
+ const retiredSearch = pendingReply()
+ let searches = 0
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths'
+ ? ++searches === 1
+ ? retiredSearch.promise
+ : Promise.resolve(missing)
+ : Promise.resolve(inventory)
+ )
+ const retired = mount(send, strict)
+ retired.query()
+ await debounce()
+ retired.dispose()
+ const successor = mount(send, strict)
+ successor.query()
+ await debounce()
+ expect(successor.paths()).toEqual(['src/apple.ts'])
+ expect(send.mock.calls).toEqual([
+ searchCall,
+ searchCall,
+ ['files.list', { worktree: 'id:folder:remote' }]
+ ])
+ await refuse(retiredSearch)
+ expect(successor.paths()).toEqual(['src/apple.ts'])
+ expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1)
+ successor.dispose()
+ }
+)
+
+it('reentrant disposal after fallback entry preserves the admitted inventory', async () => {
+ const search = pendingReply()
+ let disposal: (() => void) | undefined
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
+ )
+ const view = mount(send, false, () => {
+ disposal?.()
+ return 1
+ })
+ view.query()
+ await debounce()
+ expect(send.mock.calls).toEqual([searchCall])
+ disposal = view.dispose
+ await refuse(search)
+ expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
+})
+
+it('already admitted inventory still settles after full owner disposal', async () => {
+ const list = pendingReply()
+ const send = vi.fn((method) =>
+ method === 'files.searchPaths' ? Promise.resolve(missing) : list.promise
+ )
+ const view = mount(send)
+ view.query()
+ await debounce()
+ expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
+ view.dispose()
+ await act(async () => {
+ list.resolve(inventory)
+ await list.promise
+ })
+ expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
+ expect(vi.getTimerCount()).toBe(0)
+})
diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts
index 51ff9ab1f36..225d664e65e 100644
--- a/mobile/src/session/mobile-native-chat-message-styles.ts
+++ b/mobile/src/session/mobile-native-chat-message-styles.ts
@@ -29,6 +29,11 @@ export const styles = StyleSheet.create({
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
+ hostNotice: {
+ color: colors.textMuted,
+ fontSize: TEXT_SIZE,
+ lineHeight: TEXT_SIZE + 6
+ },
reasoning: {
opacity: 0.7
},
diff --git a/mobile/src/session/mobile-native-chat-pair-window.test.ts b/mobile/src/session/mobile-native-chat-pair-window.test.ts
new file mode 100644
index 00000000000..fb2bd15605c
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-pair-window.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'vitest'
+import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
+
+function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
+ return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] }
+}
+
+function pair(id: string): NativeChatMessage[] {
+ return [row(`${id}:reasoning`, 'reasoning'), row(id)]
+}
+
+describe('mobile complete-pair stream windows', () => {
+ it.each(['appended', 'snapshot'])(
+ 'keeps a pair at the %s cutoff without moving the cursor',
+ (type) => {
+ const merger = createNativeChatMerger()
+ const base = [
+ ...pair('opencode:oldest'),
+ ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))
+ ]
+ replaceList(merger, base)
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type,
+ messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
+ hasMore: true,
+ beforeOffset: 1
+ },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
+ expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0)
+ }
+ )
+
+ it.each(['appended', 'snapshot'])(
+ 'keeps reasoning, answer and omission from one raw row at the %s cutoff',
+ (type) => {
+ const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map(
+ (message) => ({ ...message, transcriptOffset: 7 })
+ )
+ const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))]
+ const merger = createNativeChatMerger()
+ replaceList(merger, base)
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type,
+ messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
+ hasMore: true,
+ beforeOffset: 7
+ },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
+ expect(merger.list.slice(0, 3)).toEqual(group)
+ expect(merger.list).toHaveLength(42)
+ }
+ )
+
+ it('invalidates the cursor when the oldest complete pair leaves', () => {
+ const merger = createNativeChatMerger()
+ const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))]
+ replaceList(merger, base)
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'appended', messages: [row('latest')] },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ expect(result).toEqual({
+ kind: 'messages',
+ messages: [...base.slice(2), row('latest')],
+ cursorInvalidated: true
+ })
+ expect(merger.indexById.has('opencode:oldest')).toBe(false)
+ expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false)
+ })
+
+ it('retains a host page with an extra pair row across a user append followed by a pair append', () => {
+ const merger = createNativeChatMerger()
+ const base = [
+ ...pair('opencode:first'),
+ ...Array.from({ length: 13 }, (_, i) => [
+ row(`user-${i}`, 'user'),
+ ...pair(`opencode:${i}`)
+ ]).flat()
+ ]
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 },
+ limit: 40,
+ replaceSnapshot: true
+ })
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'appended', messages: [row('latest-user', 'user')] },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'appended', messages: pair('opencode:latest') },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true })
+ expect(merger.list).toHaveLength(41)
+ expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0'))
+ expect(merger.list.slice(-2)).toEqual(pair('opencode:latest'))
+ })
+
+ it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')])
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'appended', messages: [row('latest')] },
+ limit: 2,
+ replaceSnapshot: false
+ })
+ expect(result).toEqual({
+ kind: 'messages',
+ messages: [row('claude-answer'), row('latest')],
+ cursorInvalidated: true
+ })
+ expect(merger.list).toHaveLength(2)
+ })
+})
diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
new file mode 100644
index 00000000000..3ba4220962b
--- /dev/null
+++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
@@ -0,0 +1,434 @@
+import { createElement, StrictMode } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+
+type AnimationStart = {
+ options: { toValue: number; duration: number; useNativeDriver: boolean }
+ complete: (result: { finished: boolean }) => void
+}
+const boundary = vi.hoisted(() => {
+ const animationStarts: AnimationStart[] = []
+ return {
+ animationStarts,
+ writeText: vi.fn<(text: string) => Promise>(),
+ success: vi.fn(),
+ error: vi.fn(),
+ synchronous: false
+ }
+})
+vi.mock('react-native', () => ({
+ View: 'View',
+ Platform: { OS: 'ios' },
+ Keyboard: { dismiss: vi.fn() },
+ Animated: {
+ timing: (
+ _value: unknown,
+ options: { toValue: number; duration: number; useNativeDriver: boolean }
+ ) => ({
+ start: (complete: (result: { finished: boolean }) => void) => {
+ boundary.animationStarts.push({ options, complete })
+ if (boundary.synchronous) {
+ complete({ finished: true })
+ }
+ }
+ })
+ }
+}))
+vi.mock('../platform/clipboard', () => ({
+ useClipboardWriter: () => ({ writeText: boundary.writeText }),
+ useClipboardReader: () => ({ contents: async () => ({ text: false, image: false }) })
+}))
+vi.mock('../platform/haptics', () => ({
+ triggerSuccess: boundary.success,
+ triggerError: boundary.error,
+ triggerSelection: vi.fn(),
+ triggerEdgeBump: vi.fn()
+}))
+vi.mock('../terminal/terminal-copy-gutter-preference', () => ({
+ useTerminalCopyTrimsGutter: () => ({ current: false })
+}))
+vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {} } }))
+vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
+vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
+vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
+import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
+import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection'
+import { MobileSessionSurface } from './MobileSessionSurface'
+
+type FeedbackScope = Parameters[0]
+type AccessoryScope = Parameters[0]
+type SurfaceController = Parameters[0]['controller']
+
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ boundary.animationStarts.length = 0
+ boundary.synchronous = false
+ vi.restoreAllMocks()
+})
+function mounted(strict = false) {
+ const setToastMessage = vi.fn()
+ const clearTerminalCache = vi.fn()
+ const cancelSelect = vi.fn()
+ const scope = {
+ client: null,
+ connState: 'connected',
+ initialCreateWarning: '',
+ sessionTabs: [],
+ sessionTabsRef: { current: [] },
+ activeSessionTabId: null,
+ activeSessionTabIdRef: { current: null },
+ markdownDocs: new Map(),
+ markdownDocsRef: { current: new Map() },
+ createWarningState: { source: '', visible: '' },
+ setCreateWarningState: vi.fn(),
+ setToastMessage,
+ toastOpacityRef: { current: {} },
+ toastHideTimerRef: { current: null },
+ toastSeqRef: { current: 0 },
+ clientRef: { current: null },
+ connStateRef: { current: 'connected' },
+ activeSessionTabTypeRef: { current: 'terminal' },
+ delayedActionTimersRef: { current: new Set() },
+ activeSessionTab: { type: 'terminal' },
+ worktreeId: 'folder:remote',
+ isFloatingWorkspaceRoute: false,
+ setTerminalKeyboardMetrics: vi.fn(),
+ setSelectModeActive: vi.fn(),
+ setCanPaste: vi.fn(),
+ ptyModesRef: { current: new Map() },
+ initialModesSeenRef: { current: new Set() },
+ terminalRefs: { current: new Map([['handle', { cancelSelect }]]) },
+ liveInputFocusTimerRef: { current: null },
+ sessionTabActionSheetRequestSeqRef: { current: 0 },
+ activeHandleRef: { current: 'handle' },
+ clearPendingLiveInputCommit: vi.fn(),
+ clearTerminalCache,
+ handleAccessoryKey: vi.fn(),
+ clearSessionTabActionSheetKeyboardListener: vi.fn()
+ }
+ const read: {
+ value:
+ | (ReturnType &
+ ReturnType)
+ | undefined
+ } = { value: undefined }
+ function Probe({
+ surfaceKey = 'initial',
+ visible = true
+ }: {
+ surfaceKey?: string
+ visible?: boolean
+ }) {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual feedback hook reads only the enumerated members; its type includes unrelated composed controller hooks.
+ const feedbackScope = scope as unknown as FeedbackScope
+ const feedback = useMobileSessionFeedbackCapabilities(feedbackScope)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual accessory hook reads only enumerated members plus actual feedback capability callbacks.
+ const accessoryScope = { ...scope, ...feedback } as unknown as AccessoryScope
+ const accessory = useMobileSessionAccessorySelection(accessoryScope)
+ const result = { ...feedback, ...accessory }
+ read.value = result
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual surface reads only setMobileSessionRootRef; its child components are isolated presentation stubs.
+ const controller = result as unknown as SurfaceController
+ return visible ? createElement(MobileSessionSurface, { key: surfaceKey, controller }) : null
+ }
+ let root: ReturnType | undefined
+ const element = (surfaceKey = 'initial', visible = true) =>
+ strict
+ ? createElement(StrictMode, null, createElement(Probe, { surfaceKey, visible }))
+ : createElement(Probe, { surfaceKey, visible })
+ act(() => {
+ root = create(element(), { createNodeMock: () => ({}) })
+ })
+ if (!root || !read.value) {
+ throw new Error('real composed hooks did not mount')
+ }
+ const renderer = root
+ return {
+ root,
+ get api() {
+ if (!read.value) {
+ throw new Error('real composed hooks did not render')
+ }
+ return read.value
+ },
+ scope,
+ setToastMessage,
+ clearTerminalCache,
+ cancelSelect,
+ rerender: (surfaceKey: string, visible = true) =>
+ act(() => renderer.update(element(surfaceKey, visible)))
+ }
+}
+it.each([
+ ['success', false],
+ ['failure', false],
+ ['success', true],
+ ['failure', true]
+] as const)(
+ 'keeps admitted %s copy outcome after unmount without new timers, StrictMode=%s',
+ async (outcome, strict) => {
+ vi.useFakeTimers()
+ let resolveWrite: (() => void) | undefined
+ let rejectWrite: ((error: Error) => void) | undefined
+ const clipboard = new Promise((resolve, reject) => {
+ resolveWrite = resolve
+ rejectWrite = reject
+ })
+ boundary.writeText.mockReturnValue(clipboard)
+ const view = mounted(strict)
+ const baselineClear = view.clearTerminalCache.mock.calls.length
+ const baselineSequence = view.scope.toastSeqRef.current
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ let copying: Promise | undefined
+ act(() => {
+ copying = view.api.handleSelectionCopy('handle', 'original text')
+ })
+ expect(boundary.writeText.mock.calls).toEqual([['original text']])
+ expect(boundary.animationStarts).toHaveLength(0)
+ act(() => view.root.unmount())
+ expect(view.clearTerminalCache).toHaveBeenCalledTimes(baselineClear + 1)
+ expect(view.scope.toastSeqRef.current).toBe(baselineSequence + 1)
+ expect(vi.getTimerCount()).toBe(0)
+ await act(async () => {
+ if (outcome === 'success') {
+ resolveWrite?.()
+ } else {
+ rejectWrite?.(new Error('clipboard refused'))
+ }
+ await copying
+ })
+ expect(boundary.success).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0)
+ expect(boundary.error).toHaveBeenCalledTimes(outcome === 'failure' ? 1 : 0)
+ expect(view.cancelSelect).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0)
+ expect(warn.mock.calls).toEqual(
+ outcome === 'failure'
+ ? [['[mobile-clip] setString failed', { name: 'Error', message: 'clipboard refused' }]]
+ : []
+ )
+ for (const animation of boundary.animationStarts) {
+ act(() => animation.complete({ finished: true }))
+ }
+ expect(vi.getTimerCount()).toBe(0)
+ expect(boundary.animationStarts).toHaveLength(0)
+ expect(view.setToastMessage).not.toHaveBeenCalled()
+ }
+)
+it('proves existing detach sequence already fences an admitted fade-in', () => {
+ vi.useFakeTimers()
+ const view = mounted()
+ act(() => view.api.showToast('already visible'))
+ expect(boundary.animationStarts).toHaveLength(1)
+ act(() => view.root.unmount())
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(0)
+ expect(boundary.animationStarts).toHaveLength(1)
+})
+it('proves existing detach cleanup clears a live hide timer', () => {
+ vi.useFakeTimers()
+ const view = mounted()
+ act(() => view.api.showToast('already visible'))
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => view.root.unmount())
+ expect(vi.getTimerCount()).toBe(0)
+})
+
+it.each([false, true])(
+ 'preserves late admitted copy after same-hook root replacement, StrictMode=%s',
+ async (strict) => {
+ vi.useFakeTimers()
+ let finish: (() => void) | undefined
+ boundary.writeText.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const view = mounted(strict)
+ const originalShow = view.api.showToast
+ let copying: Promise | undefined
+ act(() => {
+ copying = view.api.handleSelectionCopy('handle', 'original text')
+ })
+ const previousClear = view.clearTerminalCache.mock.calls.length
+ view.rerender('replacement')
+ expect(view.clearTerminalCache.mock.calls.length).toBeGreaterThan(previousClear)
+ expect(view.api.showToast).toBe(originalShow)
+ await act(async () => {
+ finish?.()
+ await copying
+ })
+ expect(boundary.writeText.mock.calls).toEqual([['original text']])
+ expect(boundary.success).toHaveBeenCalledTimes(1)
+ expect(view.cancelSelect).toHaveBeenCalledTimes(1)
+ expect(view.setToastMessage.mock.calls).toEqual([['Copied']])
+ expect(boundary.animationStarts).toHaveLength(1)
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => view.root.unmount())
+ expect(vi.getTimerCount()).toBe(0)
+ }
+)
+it('preserves late copy while surface is temporarily detached but hook stays mounted', async () => {
+ vi.useFakeTimers()
+ let finish: (() => void) | undefined
+ boundary.writeText.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const view = mounted()
+ let copying: Promise | undefined
+ act(() => {
+ copying = view.api.handleSelectionCopy('handle', 'original text')
+ })
+ view.rerender('inactive', false)
+ expect(view.clearTerminalCache).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ finish?.()
+ await copying
+ })
+ expect(boundary.success).toHaveBeenCalledTimes(1)
+ expect(view.cancelSelect).toHaveBeenCalledTimes(1)
+ expect(view.setToastMessage.mock.calls).toEqual([['Copied']])
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ view.rerender('active', true)
+ act(() => view.root.unmount())
+ expect(vi.getTimerCount()).toBe(0)
+})
+it.each([1200, 1500])('keeps exact live fade-in/hide/fade-out timing at %s ms', (duration) => {
+ vi.useFakeTimers()
+ const view = mounted()
+ act(() => view.api.showToast('message', duration))
+ expect(view.setToastMessage.mock.calls).toEqual([['message']])
+ expect(boundary.animationStarts[0].options).toEqual({
+ toValue: 1,
+ duration: 150,
+ useNativeDriver: true
+ })
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => {
+ vi.advanceTimersByTime(duration - 1)
+ })
+ expect(boundary.animationStarts).toHaveLength(1)
+ act(() => {
+ vi.advanceTimersByTime(1)
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(boundary.animationStarts[1].options).toEqual({
+ toValue: 0,
+ duration: 200,
+ useNativeDriver: true
+ })
+ act(() => boundary.animationStarts[1].complete({ finished: true }))
+ expect(view.setToastMessage.mock.calls).toEqual([['message'], [null]])
+ act(() => view.root.unmount())
+})
+it('preserves old unfinished/replaced animation fences and latest toast ownership', () => {
+ vi.useFakeTimers()
+ const view = mounted()
+ act(() => view.api.showToast('first'))
+ act(() => view.api.showToast('second', 1500))
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => boundary.animationStarts[1].complete({ finished: false }))
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => view.api.showToast('third'))
+ act(() => boundary.animationStarts[2].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => view.api.showToast('fourth'))
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => boundary.animationStarts[3].complete({ finished: true }))
+ act(() => {
+ vi.advanceTimersByTime(1200)
+ })
+ act(() => view.api.showToast('fifth'))
+ act(() => boundary.animationStarts[4].complete({ finished: true }))
+ expect(view.setToastMessage.mock.calls).toEqual([
+ ['first'],
+ ['second'],
+ ['third'],
+ ['fourth'],
+ ['fifth']
+ ])
+ act(() => view.root.unmount())
+})
+it('keeps 64 admitted copies after 64 surface/hook unmounts without new timers', async () => {
+ vi.useFakeTimers()
+ const releases: (() => void)[] = []
+ const copying: Promise[] = []
+ const views: ReturnType[] = []
+ for (let index = 0; index < 64; index++) {
+ boundary.writeText.mockReturnValueOnce(
+ new Promise((resolve) => {
+ releases.push(resolve)
+ })
+ )
+ const view = mounted()
+ views.push(view)
+ act(() => {
+ copying.push(view.api.handleSelectionCopy('handle', 'copy' + index))
+ })
+ act(() => view.root.unmount())
+ }
+ expect(boundary.writeText.mock.calls).toEqual(
+ Array.from({ length: 64 }, (_, index) => ['copy' + index])
+ )
+ expect(vi.getTimerCount()).toBe(0)
+ await act(async () => {
+ for (const release of releases) {
+ release()
+ }
+ await Promise.all(copying)
+ })
+ expect(boundary.success).toHaveBeenCalledTimes(64)
+ expect(boundary.error).not.toHaveBeenCalled()
+ for (const view of views) {
+ expect(view.cancelSelect).toHaveBeenCalledTimes(1)
+ expect(view.clearTerminalCache).toHaveBeenCalledTimes(1)
+ }
+ for (const animation of boundary.animationStarts) {
+ act(() => animation.complete({ finished: true }))
+ }
+ expect(vi.getTimerCount()).toBe(0)
+ expect(boundary.animationStarts).toHaveLength(0)
+})
+
+it('preserves synchronous native completion and natural hide cleanup', () => {
+ vi.useFakeTimers()
+ boundary.synchronous = true
+ const view = mounted()
+ act(() => view.api.showToast('sync'))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => {
+ vi.advanceTimersByTime(1200)
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(view.setToastMessage.mock.calls).toEqual([['sync'], [null]])
+ expect(boundary.animationStarts.map((start) => start.options)).toEqual([
+ { toValue: 1, duration: 150, useNativeDriver: true },
+ { toValue: 0, duration: 200, useNativeDriver: true }
+ ])
+ act(() => view.root.unmount())
+})
+it('preserves reentrant newer-toast sequence and animation start order', () => {
+ vi.useFakeTimers()
+ const view = mounted()
+ view.setToastMessage.mockImplementationOnce(() => view.api.showToast('inner'))
+ act(() => view.api.showToast('outer'))
+ expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner']])
+ expect(boundary.animationStarts).toHaveLength(2)
+ act(() => boundary.animationStarts[0].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => boundary.animationStarts[1].complete({ finished: true }))
+ expect(vi.getTimerCount()).toBe(1)
+ act(() => {
+ vi.advanceTimersByTime(1200)
+ })
+ act(() => boundary.animationStarts[2].complete({ finished: true }))
+ expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner'], [null]])
+ act(() => view.root.unmount())
+})
diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts
index a71ad83829d..8fe58a8ccd6 100644
--- a/mobile/src/session/use-mobile-native-chat-file-search.ts
+++ b/mobile/src/session/use-mobile-native-chat-file-search.ts
@@ -34,6 +34,14 @@ export function useMobileNativeChatFileSearch(args: {
new GenerationScopedRequestOwner()
).current
+ const mountedRef = useRef(true)
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ }
+ }, [])
+
useEffect(() => {
sequenceRef.current++
queryCacheRef.current.clear()
@@ -90,6 +98,9 @@ export function useMobileNativeChatFileSearch(args: {
setNativeChatFilePaths(paths)
}
const loadLegacyPaths = async (): Promise => {
+ if (!mountedRef.current) {
+ return
+ }
// What retires the inventory: this host, this workspace, this logical authority. A
// reconnect to the same host leaves the files on disk alone, so the physical session
// epoch is deliberately not in it. Read once, so a cutover between the two calls below
diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts
index ea8d6438bf4..ad0fb781363 100644
--- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts
+++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts
@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback } from 'react'
+import { useState, useRef, useCallback, useEffect } from 'react'
import { Animated } from 'react-native'
import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state'
import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime'
@@ -60,6 +60,14 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
}
const createWarning = reconciledCreateWarningState.visible
+ const mountedRef = useRef(true)
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ }
+ }, [])
+
const clearDelayedActionTimers = useCallback(() => {
for (const timer of delayedActionTimersRef.current) {
clearTimeout(timer)
@@ -85,6 +93,9 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
const showToast = useCallback(
(message: string, durationMs = 1200) => {
+ if (!mountedRef.current) {
+ return
+ }
const seq = toastSeqRef.current + 1
toastSeqRef.current = seq
clearToastHideTimer()
diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts
index 67ffa4a7e13..940b04e0645 100644
--- a/mobile/src/test-support/rpc-recording/derived-goldens.ts
+++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts
@@ -53,6 +53,24 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor
return found
}
+function omitRetiredInventoryAdmission(scenario: RecordingScenario): RecordingScenario {
+ if (scenario.id !== 'inventory-lifecycle.unmount-before-1') {
+ return scenario
+ }
+ // A late search refusal cannot start an inventory after this owner's unmount.
+ return {
+ ...scenario,
+ steps: scenario.steps.filter(
+ (step) =>
+ !(
+ ('bind' in step &&
+ (step.bind === 'old-inventory' || step.bind === 'lifecycle-old-inventory')) ||
+ ('complete' in step && step.complete === 'lifecycle-old-inventory')
+ )
+ )
+ }
+}
+
/** The manifest scenario a pilot golden expands from, which its suite also mounts and mutates. */
export type PilotGolden = DerivedGolden & { scenario: RecordingScenario }
@@ -144,7 +162,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo
actions
.flatMap((action) => lifecycleSchedules(base, action))
.filter(({ scenario }) => !id.includes('hydration') || !scenario.id.endsWith('-1'))
- )
+ ).map(omitRetiredInventoryAdmission)
})
}
return goldens
diff --git a/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts
new file mode 100644
index 00000000000..9cf6c5947bc
--- /dev/null
+++ b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts
@@ -0,0 +1,64 @@
+import { resolve } from 'node:path'
+import { expect, it } from 'vitest'
+import { familyGoldens } from './derived-goldens'
+import { hoistPreludeCheckpoints } from './prelude-checkpoints'
+import { readScenarios } from './scenario-input'
+import { bindCompletions, lifecycleSchedules } from './schedule-driver'
+
+it('omits only the inventory admission following a refusal to a fully unmounted owner', () => {
+ const manifest = readScenarios(
+ resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json')
+ )
+ const base = manifest.scenarios.find((scenario) => scenario.id === 'inventory-lifecycle')
+ if (!base) {
+ throw new Error('missing inventory base scenario')
+ }
+ const golden = familyGoldens(manifest.scenarios).find(
+ (item) => item.id === 'lifecycle-inventory-lifecycle'
+ )
+ if (!golden) {
+ throw new Error('missing inventory lifecycle golden')
+ }
+ const original = hoistPreludeCheckpoints(
+ { ...base, steps: bindCompletions(base.steps) },
+ (['reset', 'unmount', 'blur'] as const).flatMap((action) => lifecycleSchedules(base, action))
+ )
+ const derived = golden.scenarios()
+ expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id))
+ expect(derived).toHaveLength(12)
+ for (const [index, scenario] of original.entries()) {
+ if (scenario.id !== 'inventory-lifecycle.unmount-before-1') {
+ expect(derived[index]).toEqual(scenario)
+ expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario))
+ continue
+ }
+ expect(scenario.steps).toHaveLength(13)
+ expect(scenario.steps.slice(7, 10)).toEqual([
+ { bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } },
+ { bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } },
+ {
+ complete: 'lifecycle-old-inventory',
+ params: { worktree: 'id:A' },
+ reply: { ok: true, result: { files: [{ relativePath: 'old.ts' }] } }
+ }
+ ])
+ const expected = {
+ ...scenario,
+ steps: [...scenario.steps.slice(0, 7), ...scenario.steps.slice(10)]
+ }
+ expect(derived[index]).toEqual(expected)
+ expect(JSON.stringify(derived[index])).toBe(JSON.stringify(expected))
+ expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([
+ { checkpoint: 'lifecycle-boundary' },
+ { checkpoint: 'settled' },
+ { checkpoint: 'remounted' }
+ ])
+ expect(expected.steps.filter((step) => 'bind' in step)).toEqual([
+ {
+ bind: 'lifecycle-files.searchPaths#1',
+ request: 'files.searchPaths#1',
+ params: { worktree: 'id:A', query: 'old', limit: 16 }
+ }
+ ])
+ }
+})
diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts
index c2f24f71823..05f66979e8b 100644
--- a/mobile/src/transport/mobile-relay-e2ee-link.ts
+++ b/mobile/src/transport/mobile-relay-e2ee-link.ts
@@ -130,6 +130,9 @@ export class MobileRelayE2eeLink {
// `error` is often delivered just before `close`; wait for close so a
// typed relay code is not replaced by a generic transport error.
this.socket.onerror = () => {
+ if (this.closed) {
+ return
+ }
this.transportErrorTimer ??= setTimeout(() => {
this.transportErrorTimer = null
this.fail(new RelayOuterError(1006))
diff --git a/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts
new file mode 100644
index 00000000000..4727f34646e
--- /dev/null
+++ b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
+
+vi.mock('./mobile-e2ee-v2-client-session', () => ({
+ MobileE2EEV2ClientSession: { create: () => ({}) }
+}))
+
+vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
+ MobileE2EEV2PhysicalChannel: class {
+ start = vi.fn()
+ handleMessage = vi.fn(async () => {})
+ sendText = vi.fn(() => true)
+ sendBinary = vi.fn(() => true)
+ dispose = vi.fn()
+ }
+}))
+
+import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link'
+
+class ErrorTimerSocket {
+ readonly OPEN = 1
+ readyState = 1
+ bufferedAmount = 0
+ onopen: (() => void) | null = null
+ onmessage: ((event: { data: unknown }) => void) | null = null
+ onerror: (() => void) | null = null
+ onclose: ((event: { code: number; reason: string }) => void) | null = null
+ send = vi.fn((_frame: string) => {})
+ close = vi.fn(() => {})
+}
+
+function linkFixture(): {
+ link: MobileRelayE2eeLink
+ socket: ErrorTimerSocket
+ onError: ReturnType
+ onHostCloseReason: ReturnType
+} {
+ const socket = new ErrorTimerSocket()
+ const onError = vi.fn()
+ const onHostCloseReason = vi.fn()
+ const link = new MobileRelayE2eeLink({
+ endpoint: {
+ cellUrl: 'https://relay-c1.onorca.dev',
+ relayHostId: 'AbCdEf0123_-xyZ9'
+ },
+ credential: 'credential',
+ expectedCredentialKind: 'resume',
+ deviceToken: 'device-token',
+ desktopPublicKeyB64: 'desktop-key',
+ onAuthenticated: vi.fn(),
+ onText: vi.fn(),
+ onBinary: vi.fn(),
+ onError,
+ onHostCloseReason,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this link and mocked channel use.
+ createSocket: () => socket as unknown as WebSocket
+ })
+ return { link, socket, onError, onHostCloseReason }
+}
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+})
+
+describe('closed relay link transport-error timer ownership', () => {
+ it('starts no fallback for errors queued after explicit close', () => {
+ const { link, socket, onError } = linkFixture()
+ link.close()
+ socket.onerror?.()
+ socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(onError).not.toHaveBeenCalled()
+ expect(socket.close).toHaveBeenCalledOnce()
+ })
+
+ it('starts no fallback after failure and preserves the original thrown error', () => {
+ const { socket, onError } = linkFixture()
+ const failure = new Error('relay auth write failed')
+ socket.send.mockImplementation(() => {
+ throw failure
+ })
+ socket.onopen?.()
+ socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(failure)
+ expect(socket.close).toHaveBeenCalledOnce()
+ })
+
+ it.each(['close', 'fail', 'fallback'])(
+ 'starts no fallback for a reentrant socket.close error during %s',
+ async (mode) => {
+ const { link, socket, onError } = linkFixture()
+ socket.close.mockImplementation(() => {
+ socket.onerror?.()
+ })
+ if (mode === 'close') {
+ link.close()
+ expect(onError).not.toHaveBeenCalled()
+ } else if (mode === 'fail') {
+ socket.onclose?.({ code: 4409, reason: '' })
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_4409' })
+ )
+ } else {
+ socket.onerror?.()
+ await vi.advanceTimersByTimeAsync(250)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_1006' })
+ )
+ }
+ expect(vi.getTimerCount()).toBe(0)
+ expect(socket.close).toHaveBeenCalledOnce()
+ }
+ )
+
+ it('starts no fallback after normal close and still reports its host reason', () => {
+ const { socket, onError, onHostCloseReason } = linkFixture()
+ socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
+ socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_4409' })
+ )
+ expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
+ })
+
+ it('retains one live fallback and the exact missing-close grace deadline', async () => {
+ const { socket, onError } = linkFixture()
+ socket.onerror?.()
+ socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(1)
+ await vi.advanceTimersByTimeAsync(249)
+ expect(onError).not.toHaveBeenCalled()
+ expect(socket.close).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_1006' })
+ )
+ expect(socket.close).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('retains a typed close and host reason during the live error grace', async () => {
+ const { socket, onError, onHostCloseReason } = linkFixture()
+ socket.onerror?.()
+ await vi.advanceTimersByTimeAsync(249)
+ socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
+ expect(vi.getTimerCount()).toBe(0)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_4409' })
+ )
+ expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
+ })
+
+ it('retains late host-reason reporting after a rejected handshake', async () => {
+ const { socket, onError, onHostCloseReason } = linkFixture()
+ socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4409 }) })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_4409' })
+ )
+ socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
+ expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
+ expect(onError).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('keeps a replacement link independent and leaves no timer after its close', async () => {
+ const old = linkFixture()
+ old.link.close()
+ const replacement = linkFixture()
+ old.socket.onerror?.()
+ replacement.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(1)
+ replacement.socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
+ expect(vi.getTimerCount()).toBe(0)
+ await vi.advanceTimersByTimeAsync(250)
+ expect(old.onError).not.toHaveBeenCalled()
+ expect(replacement.onError).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ message: 'relay_outer_4409' })
+ )
+ expect(replacement.onHostCloseReason).toHaveBeenCalledExactlyOnceWith(
+ RELAY_HOST_CLOSE_REASON.SIGNED_OUT
+ )
+ })
+})
diff --git a/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts
new file mode 100644
index 00000000000..13236d69cc7
--- /dev/null
+++ b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts
@@ -0,0 +1,253 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectionLogEntry } from './types'
+
+type ChannelRecord = {
+ options: { onAuthenticated(): void; onText(value: string): void; onError(error: Error): void }
+ start: ReturnType
+ sendText: ReturnType
+ dispose: ReturnType
+}
+
+const observed = vi.hoisted(() => ({ channels: new Array() }))
+vi.mock('./mobile-e2ee-v2-client-session', () => ({
+ MobileE2EEV2ClientSession: { create: () => ({}) }
+}))
+vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
+ MobileE2EEV2PhysicalChannel: class {
+ start = vi.fn()
+ handleMessage = vi.fn(async () => {})
+ sendText = vi.fn((_frame: string) => true)
+ dispose = vi.fn()
+ constructor(readonly options: ChannelRecord['options']) {
+ observed.channels.push(this)
+ }
+ }
+}))
+
+import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client'
+
+class PairingTimerSocket {
+ readonly OPEN = 1
+ readyState = 1
+ bufferedAmount = 0
+ onopen: (() => void) | null = null
+ onmessage: ((event: { data: unknown }) => void) | null = null
+ onerror: (() => void) | null = null
+ onclose: ((event: { code: number }) => void) | null = null
+ send = vi.fn((_frame: string) => {})
+ close = vi.fn(() => {})
+}
+
+function fixture() {
+ const socket = new PairingTimerSocket()
+ const logs: ConnectionLogEntry[] = []
+ const client = connectMobileRelayForPairing({
+ relay: {
+ v: 1,
+ directorUrl: 'https://relay.onorca.dev',
+ cellUrl: 'https://relay-c1.onorca.dev',
+ assignmentEpoch: 7,
+ relayHostId: 'AbCdEf0123_-xyZ9',
+ inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
+ inviteExpiresAt: Date.now() + 300_000,
+ e2eeFraming: 2
+ },
+ deviceToken: 'device-token',
+ desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
+ onLog: (entry) => logs.push(entry),
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this client and mocked channel use.
+ createSocket: () => socket as unknown as WebSocket
+ })
+ const channel = observed.channels.at(-1)
+ if (!channel) {
+ throw new Error('missing observed pairing channel')
+ }
+ return { client, socket, logs, channel }
+}
+
+async function authenticate(target: ReturnType): Promise {
+ target.socket.onmessage?.({
+ data: JSON.stringify({
+ type: 'relay-hello',
+ ok: true,
+ credentialKind: 'invite',
+ leaseExpiresAt: Date.now() + 60_000
+ })
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(target.channel.start).toHaveBeenCalledOnce()
+ target.channel.options.onAuthenticated()
+}
+
+function closedLogs(logs: ConnectionLogEntry[]): ConnectionLogEntry[] {
+ return logs.filter((entry) => entry.message === 'Relay: pairing socket closed')
+}
+
+beforeEach(() => {
+ observed.channels.length = 0
+ vi.useFakeTimers()
+})
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+})
+
+describe('closed relay pairing client error timer ownership', () => {
+ it('keeps authentication waiter rejection and intentional-close log exact without a late alarm', async () => {
+ const target = fixture()
+ const first = target.client.sendRequest('first').catch((error: unknown) => error)
+ const second = target.client.sendRequest('second').catch((error: unknown) => error)
+ target.client.close()
+ const failure = await first
+ expect(failure).toEqual(new Error('relay pairing client closed'))
+ expect(await second).toBe(failure)
+ target.socket.onerror?.()
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'info', detail: 'relay-c1.onorca.dev' })
+ ])
+ expect(target.channel.dispose).toHaveBeenCalledOnce()
+ expect(target.socket.close).toHaveBeenCalledOnce()
+ })
+
+ it('keeps the original error for every pending RPC and clears their timers before late errors', async () => {
+ const target = fixture()
+ await authenticate(target)
+ const first = target.client.sendRequest('first').catch((error: unknown) => error)
+ const second = target.client.sendRequest('second').catch((error: unknown) => error)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(vi.getTimerCount()).toBe(2)
+ const failure = new Error('channel failed')
+ target.channel.options.onError(failure)
+ expect(await first).toBe(failure)
+ expect(await second).toBe(failure)
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'warn', detail: 'Error: channel failed' })
+ ])
+ expect(target.channel.dispose).toHaveBeenCalledOnce()
+ expect(target.socket.close).toHaveBeenCalledOnce()
+ })
+
+ it.each(['intentional', 'channel', 'fallback'])(
+ 'starts no timer for reentrant socket.close errors during %s',
+ async (mode) => {
+ const target = fixture()
+ const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
+ target.socket.close.mockImplementation(() => target.socket.onerror?.())
+ const failure = new Error('channel failed')
+ if (mode === 'intentional') {
+ target.client.close()
+ expect(await waiting).toEqual(new Error('relay pairing client closed'))
+ } else if (mode === 'channel') {
+ target.channel.options.onError(failure)
+ expect(await waiting).toBe(failure)
+ } else {
+ target.socket.onerror?.()
+ await vi.advanceTimersByTimeAsync(250)
+ expect(await waiting).toEqual(new RelayOuterError(1006))
+ }
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toHaveLength(1)
+ expect(target.channel.dispose).toHaveBeenCalledOnce()
+ expect(target.socket.close).toHaveBeenCalledOnce()
+ }
+ )
+
+ it('keeps a normal typed close rejection and log without a late alarm', async () => {
+ const target = fixture()
+ const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
+ target.socket.onclose?.({ code: 4409 })
+ expect(await waiting).toEqual(new RelayOuterError(4409))
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' })
+ ])
+ })
+
+ it('keeps a rejected hello error and log without a late alarm', async () => {
+ const target = fixture()
+ const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
+ target.socket.onmessage?.({
+ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })
+ })
+ expect(await waiting).toEqual(new RelayOuterError(4404))
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'warn', detail: 'relay close code 4404' })
+ ])
+ expect(target.channel.start).not.toHaveBeenCalled()
+ })
+
+ it('retains one live alarm and exactly the 250 ms missing-close grace', async () => {
+ const target = fixture()
+ const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
+ target.socket.onerror?.()
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(1)
+ await vi.advanceTimersByTimeAsync(249)
+ expect(closedLogs(target.logs)).toHaveLength(0)
+ expect(target.channel.dispose).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(await waiting).toEqual(new RelayOuterError(1006))
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'warn', detail: 'relay close code 1006' })
+ ])
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('retains a typed close during the live error grace', async () => {
+ const target = fixture()
+ const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
+ target.socket.onerror?.()
+ await vi.advanceTimersByTimeAsync(249)
+ target.socket.onclose?.({ code: 4409 })
+ expect(await waiting).toEqual(new RelayOuterError(4409))
+ await vi.advanceTimersByTimeAsync(1)
+ expect(vi.getTimerCount()).toBe(0)
+ expect(closedLogs(target.logs)).toEqual([
+ expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' })
+ ])
+ })
+
+ it('preserves an already settled RPC through explicit close and late errors', async () => {
+ const target = fixture()
+ await authenticate(target)
+ const pending = target.client.sendRequest('status.get')
+ await vi.advanceTimersByTimeAsync(0)
+ target.channel.options.onText(JSON.stringify({ id: 'relay-pair-1', ok: true, result: 'done' }))
+ const response = await pending
+ expect(response).toEqual({ id: 'relay-pair-1', ok: true, result: 'done' })
+ target.client.close()
+ target.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(await pending).toBe(response)
+ expect(closedLogs(target.logs)).toHaveLength(1)
+ expect(target.channel.dispose).toHaveBeenCalledOnce()
+ })
+
+ it('lets a replacement own its single alarm and leaves none after its close', async () => {
+ const old = fixture()
+ const oldWaiting = old.client.sendRequest('old').catch((error: unknown) => error)
+ old.client.close()
+ expect(await oldWaiting).toEqual(new Error('relay pairing client closed'))
+ const replacement = fixture()
+ const waiting = replacement.client.sendRequest('new').catch((error: unknown) => error)
+ old.socket.onerror?.()
+ replacement.socket.onerror?.()
+ expect(vi.getTimerCount()).toBe(1)
+ replacement.socket.onclose?.({ code: 4409 })
+ expect(await waiting).toEqual(new RelayOuterError(4409))
+ expect(vi.getTimerCount()).toBe(0)
+ await vi.advanceTimersByTimeAsync(250)
+ expect(closedLogs(old.logs)).toHaveLength(1)
+ expect(closedLogs(replacement.logs)).toHaveLength(1)
+ expect(old.channel.dispose).toHaveBeenCalledOnce()
+ expect(replacement.channel.dispose).toHaveBeenCalledOnce()
+ })
+})
diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts
index 3df30542ffa..0883f6bf14a 100644
--- a/mobile/src/transport/mobile-relay-physical-client.ts
+++ b/mobile/src/transport/mobile-relay-physical-client.ts
@@ -118,6 +118,9 @@ export function connectMobileRelayForPairing(args: {
// WebSocket implementations commonly emit `error` immediately before
// `close`; the bounded fallback represents an opaque 1006 close.
socket.onerror = () => {
+ if (closed) {
+ return
+ }
transportErrorTimer ??= setTimeout(() => {
transportErrorTimer = null
fail(new RelayOuterError(1006))
diff --git a/mobile/src/worktree/agent-row-lineage-parity.test.ts b/mobile/src/worktree/agent-row-lineage-parity.test.ts
new file mode 100644
index 00000000000..10a73d47a4f
--- /dev/null
+++ b/mobile/src/worktree/agent-row-lineage-parity.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import {
+ buildAgentRowLineageTree,
+ flattenAgentRowLineage,
+ type AgentRowNode
+} from './agent-row-lineage'
+
+function row(paneKey: string, parentPaneKey: string | null): RuntimeWorktreeAgentRow {
+ return {
+ paneKey,
+ parentPaneKey,
+ state: 'working',
+ agentType: 'claude',
+ prompt: '',
+ taskTitle: null,
+ displayName: null,
+ lastAssistantMessage: null,
+ toolName: null,
+ toolInput: null,
+ interrupted: false,
+ stateStartedAt: 0,
+ updatedAt: 0
+ }
+}
+
+// Frozen pre-optimization traversal: duplicate pane keys may be emitted on distinct branches.
+function legacyFlatten(rows: readonly RuntimeWorktreeAgentRow[]): AgentRowNode[] {
+ const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows)
+ const out: AgentRowNode[] = []
+ const seen = new Set()
+ const visit = (agent: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => {
+ if (ancestors.has(agent.paneKey)) {
+ return
+ }
+ seen.add(agent.paneKey)
+ out.push({ row: agent, depth, children: [] })
+ const nextAncestors = new Set(ancestors)
+ nextAncestors.add(agent.paneKey)
+ for (const child of childrenByParentPaneKey.get(agent.paneKey) ?? []) {
+ visit(child, depth + 1, nextAncestors)
+ }
+ }
+ for (const root of rootRows) {
+ visit(root, 0, new Set())
+ }
+ for (const agent of rows) {
+ if (!seen.has(agent.paneKey)) {
+ seen.add(agent.paneKey)
+ out.push({ row: agent, depth: 0, children: [] })
+ }
+ }
+ return out
+}
+
+describe('agent lineage traversal parity', () => {
+ it('matches the previous traversal across cycles, dangling parents, duplicates, and input order', () => {
+ const variants = ['a', 'b', 'c'].flatMap((paneKey) =>
+ [null, 'a', 'b', 'c', 'missing'].map((parentPaneKey) => row(paneKey, parentPaneKey))
+ )
+ expect(flattenAgentRowLineage([])).toEqual(legacyFlatten([]))
+ for (const first of variants) {
+ for (const second of variants) {
+ for (const third of variants) {
+ const rows = [first, second, third]
+ const expected = legacyFlatten(rows)
+ const actual = flattenAgentRowLineage(rows)
+ expect(actual).toEqual(expected)
+ actual.forEach((node, index) => expect(node.row).toBe(expected[index]?.row))
+ }
+ }
+ }
+ })
+
+ it('releases the ancestor path between duplicate roots and sibling branches', () => {
+ const firstRoot = row('root', null)
+ const secondRoot = row('root', null)
+ const firstChild = row('child', 'root')
+ const secondChild = row('child', 'root')
+ const grandchild = row('grandchild', 'child')
+ const rows = [firstRoot, firstChild, secondChild, grandchild, secondRoot]
+ const actual = flattenAgentRowLineage(rows)
+ expect(actual).toEqual(legacyFlatten(rows))
+ expect(actual.map((node) => [rows.indexOf(node.row), node.depth])).toEqual([
+ [0, 0],
+ [1, 1],
+ [3, 2],
+ [2, 1],
+ [3, 2],
+ [4, 0],
+ [1, 1],
+ [3, 2],
+ [2, 1],
+ [3, 2]
+ ])
+ })
+
+ it('copies no ancestor members while traversing a long lineage', () => {
+ const rows = Array.from({ length: 512 }, (_, index) =>
+ row(`pane-${index}`, index === 0 ? null : `pane-${index - 1}`)
+ )
+ const NativeSet = globalThis.Set
+ let copiedMembers = 0
+ class ObservedSet extends NativeSet {
+ constructor(values?: Iterable | null) {
+ super()
+ if (values) {
+ for (const value of values) {
+ copiedMembers += 1
+ this.add(value)
+ }
+ }
+ }
+ }
+ vi.stubGlobal('Set', ObservedSet)
+ let actual: AgentRowNode[]
+ try {
+ actual = flattenAgentRowLineage(rows)
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ expect(copiedMembers).toBe(0)
+ expect(actual.map((node) => [node.row.paneKey, node.depth])).toEqual(
+ rows.map((agent, index) => [agent.paneKey, index])
+ )
+ })
+})
diff --git a/mobile/src/worktree/agent-row-lineage.ts b/mobile/src/worktree/agent-row-lineage.ts
index 4d3fe7ffe25..2c48c5b09c9 100644
--- a/mobile/src/worktree/agent-row-lineage.ts
+++ b/mobile/src/worktree/agent-row-lineage.ts
@@ -59,21 +59,22 @@ export function flattenAgentRowLineage(rows: readonly RuntimeWorktreeAgentRow[])
const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows)
const out: AgentRowNode[] = []
const seen = new Set()
- const visit = (row: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => {
+ const ancestors = new Set()
+ const visit = (row: RuntimeWorktreeAgentRow, depth: number) => {
if (ancestors.has(row.paneKey)) {
return
}
seen.add(row.paneKey)
const node: AgentRowNode = { row, depth, children: [] }
out.push(node)
- const nextAncestors = new Set(ancestors)
- nextAncestors.add(row.paneKey)
+ ancestors.add(row.paneKey)
for (const child of childrenByParentPaneKey.get(row.paneKey) ?? []) {
- visit(child, depth + 1, nextAncestors)
+ visit(child, depth + 1)
}
+ ancestors.delete(row.paneKey)
}
for (const root of rootRows) {
- visit(root, 0, new Set())
+ visit(root, 0)
}
// Why: a cyclic component that coexists with a normal rooted tree has no entry
// in rootRows and is unreachable from any root, so it would silently vanish.
diff --git a/package.json b/package.json
index 3e67dfb4813..3c1979d5ff0 100644
--- a/package.json
+++ b/package.json
@@ -182,7 +182,6 @@
"@floating-ui/dom": "1.8.0",
"@linear/sdk": "^97.0.0",
"@parcel/watcher": "^2.5.6",
- "@streamparser/json": "0.0.26",
"@xterm/addon-serialize": "0.15.0-beta.300",
"@xterm/headless": "6.1.0-beta.302",
"agent-browser": "~0.27.0",
@@ -198,6 +197,8 @@
"sherpa-onnx": "1.12.37",
"smol-toml": "1.8.0",
"ssh2": "^1.17.0",
+ "stream-chain": "4.2.6",
+ "stream-json": "3.7.0",
"tldts": "7.4.16",
"tweetnacl": "^1.0.3",
"ws": "^8.22.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9fb1428002d..fbf12ea1ae1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -196,9 +196,6 @@ importers:
'@parcel/watcher':
specifier: ^2.5.6
version: 2.5.6
- '@streamparser/json':
- specifier: 0.0.26
- version: 0.0.26
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))
@@ -244,6 +241,12 @@ importers:
ssh2:
specifier: ^1.17.0
version: 1.17.0
+ stream-chain:
+ specifier: 4.2.6
+ version: 4.2.6
+ stream-json:
+ specifier: 3.7.0
+ version: 3.7.0
tldts:
specifier: 7.4.16
version: 7.4.16
@@ -3185,9 +3188,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
- '@streamparser/json@0.0.26':
- resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==}
-
'@swc/core-darwin-arm64@1.15.46':
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
engines: {node: '>=10'}
@@ -7354,6 +7354,12 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
+ stream-chain@4.2.6:
+ resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==}
+
+ stream-json@3.7.0:
+ resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -10208,8 +10214,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
- '@streamparser/json@0.0.26': {}
-
'@swc/core-darwin-arm64@1.15.46':
optional: true
@@ -14965,6 +14969,12 @@ snapshots:
stdin-discarder@0.3.2: {}
+ stream-chain@4.2.6: {}
+
+ stream-json@3.7.0:
+ dependencies:
+ stream-chain: 4.2.6
+
strict-event-emitter@0.5.1:
optional: true
diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts
index c65a96ebe5c..2db6b9785fc 100644
--- a/src/cli/handlers/search.test.ts
+++ b/src/cli/handlers/search.test.ts
@@ -63,7 +63,17 @@ async function runSearch(
): Promise<{ call: ReturnType; output: string }> {
const call = options.error
? vi.fn().mockRejectedValue(options.error)
- : vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
+ : vi
+ .fn()
+ .mockImplementation((method: string) =>
+ Promise.resolve(
+ envelope(
+ method === 'aiVault.searchStatus'
+ ? statusResponse
+ : (options.result ?? resultsResponse)
+ )
+ )
+ )
const lines: string[] = []
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
lines.push(String(value))
@@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => {
]
]
- it.each(flagCases)('sends %s', async (_name, flags, params) => {
+ it.each(flagCases)('sends %s', async (name, flags, params) => {
const { call } = await runSearch(flags)
+ expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1)
+ if (name === 'filters') {
+ expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {})
+ }
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', {
...params,
supportedAgents: [...AI_VAULT_AGENTS],
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt
index 2d161a01289..c8de61a9fda 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
+if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+fi
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt
index 2187aaca0f0..18850b4a0ef 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
+__orca_deferred_line_init() {
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) || return 0
+ local __orca_direct_line_init=0
+ [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
+ __orca_deferred_init
+ if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
+ zle zle-line-init "$@"
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
+ local __orca_prev_line_init_fn=""
+ __orca_prompt_mark "$@"
+ fi
+}
+# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
+__orca_deferred_sched_init() {
+ local __orca_prompt_status=$?
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
+ builtin unset __orca_deferred_sched_armed
+ builtin unfunction __orca_deferred_sched_init
+ return $__orca_prompt_status
+}
+__orca_arm_deferred_line_init() {
+ builtin emulate -L zsh
+ if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
+ if (( ${+widgets[zle-line-init]} )); then
+ zle -A zle-line-init __orca_saved_line_init
+ fi
+ zle -N zle-line-init __orca_deferred_line_init
+ fi
+ if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
+ builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
+ fi
+}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,12 +88,27 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
+ if (( ${+widgets[__orca_saved_line_init]} )); then
+ if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -A __orca_saved_line_init zle-line-init
+ fi
+ zle -D __orca_saved_line_init
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -D zle-line-init
+ fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
+ (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
fi
+ if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+ fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -202,13 +251,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
- builtin unfunction __orca_deferred_init __orca_has_feature
+ (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
+ local __orca_widget __orca_line_init_bound=0
+ for __orca_widget in "${(v)widgets[@]}"; do
+ if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
+ __orca_line_init_bound=1
+ break
+ fi
+ done
+ (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
+ builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
- builtin typeset -ag precmd_functions
- (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ if (( ! $+_orca_deferred_init_done )); then
+ builtin typeset -ag precmd_functions
+ (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ __orca_arm_deferred_line_init
+ fi
}
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt
index fd151e03b12..05f3da594e0 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
+if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+fi
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt
index 7172b28d2dc..b3c8a92869c 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
+__orca_deferred_line_init() {
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) || return 0
+ local __orca_direct_line_init=0
+ [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
+ __orca_deferred_init
+ if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
+ zle zle-line-init "$@"
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
+ local __orca_prev_line_init_fn=""
+ __orca_prompt_mark "$@"
+ fi
+}
+# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
+__orca_deferred_sched_init() {
+ local __orca_prompt_status=$?
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
+ builtin unset __orca_deferred_sched_armed
+ builtin unfunction __orca_deferred_sched_init
+ return $__orca_prompt_status
+}
+__orca_arm_deferred_line_init() {
+ builtin emulate -L zsh
+ if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
+ if (( ${+widgets[zle-line-init]} )); then
+ zle -A zle-line-init __orca_saved_line_init
+ fi
+ zle -N zle-line-init __orca_deferred_line_init
+ fi
+ if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
+ builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
+ fi
+}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,8 +88,17 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
+ if (( ${+widgets[__orca_saved_line_init]} )); then
+ if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -A __orca_saved_line_init zle-line-init
+ fi
+ zle -D __orca_saved_line_init
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -D zle-line-init
+ fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
+ (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
@@ -67,6 +110,12 @@ __orca_deferred_init() {
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
fi
fi
+ if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+ fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -219,13 +268,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
- builtin unfunction __orca_deferred_init __orca_has_feature
+ (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
+ local __orca_widget __orca_line_init_bound=0
+ for __orca_widget in "${(v)widgets[@]}"; do
+ if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
+ __orca_line_init_bound=1
+ break
+ fi
+ done
+ (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
+ builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
- builtin typeset -ag precmd_functions
- (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ if (( ! $+_orca_deferred_init_done )); then
+ builtin typeset -ag precmd_functions
+ (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ __orca_arm_deferred_line_init
+ fi
}
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt
index 3042bb11df4..e9cc47d8361 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt
@@ -26,6 +26,12 @@ fi
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
+if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+fi
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
# their normal argv shape.
diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt
index 102534564b7..21dce8480e6 100644
--- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt
+++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
builtin unset ORCA_HISTFILE
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
+__orca_deferred_line_init() {
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) || return 0
+ local __orca_direct_line_init=0
+ [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
+ __orca_deferred_init
+ if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
+ zle zle-line-init "$@"
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
+ local __orca_prev_line_init_fn=""
+ __orca_prompt_mark "$@"
+ fi
+}
+# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
+__orca_deferred_sched_init() {
+ local __orca_prompt_status=$?
+ builtin emulate -L zsh
+ (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
+ builtin unset __orca_deferred_sched_armed
+ builtin unfunction __orca_deferred_sched_init
+ return $__orca_prompt_status
+}
+__orca_arm_deferred_line_init() {
+ builtin emulate -L zsh
+ if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
+ if (( ${+widgets[zle-line-init]} )); then
+ zle -A zle-line-init __orca_saved_line_init
+ fi
+ zle -N zle-line-init __orca_deferred_line_init
+ fi
+ if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
+ builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
+ fi
+}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -40,7 +74,21 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
+ if (( ${+widgets[__orca_saved_line_init]} )); then
+ if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -A __orca_saved_line_init zle-line-init
+ fi
+ zle -D __orca_saved_line_init
+ elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
+ zle -D zle-line-init
+ fi
precmd_functions=(${precmd_functions:#__orca_deferred_init})
+ if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
+ case "${PATH:-}" in
+ "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
+ *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
+ esac
+ fi
if __orca_has_feature overlay; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
@@ -170,13 +218,25 @@ __orca_deferred_init() {
zle -N zle-line-init __orca_prompt_mark
fi
builtin unset _orca_shell_features _orca_histfile
- builtin unfunction __orca_deferred_init __orca_has_feature
+ (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
+ local __orca_widget __orca_line_init_bound=0
+ for __orca_widget in "${(v)widgets[@]}"; do
+ if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
+ __orca_line_init_bound=1
+ break
+ fi
+ done
+ (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
+ builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
- builtin typeset -ag precmd_functions
- (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ if (( ! $+_orca_deferred_init_done )); then
+ builtin typeset -ag precmd_functions
+ (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
+ __orca_arm_deferred_line_init
+ fi
}
diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts
index 4cd1905d8aa..30fe4a658a9 100644
--- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts
+++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
homedirMock.mockReturnValue(home)
const previousGrokHome = process.env.GROK_HOME
const previousKimiHome = process.env.KIMI_CODE_HOME
+ const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
delete process.env.GROK_HOME
delete process.env.KIMI_CODE_HOME
+ delete process.env.XDG_CONFIG_HOME
try {
await withPlatform('win32', () => {
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
} else {
process.env.KIMI_CODE_HOME = previousKimiHome
}
+ if (previousXdgConfigHome === undefined) {
+ delete process.env.XDG_CONFIG_HOME
+ } else {
+ process.env.XDG_CONFIG_HOME = previousXdgConfigHome
+ }
rmSync(home, { recursive: true, force: true })
}
})
diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts
new file mode 100644
index 00000000000..19a6efd5a89
--- /dev/null
+++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts
@@ -0,0 +1,146 @@
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import { AgentHookServer } from './server'
+import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
+import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
+import { PANE } from './server.test-fixtures'
+
+describe('startup prompt control with status hooks disabled', () => {
+ it.each([false, true])(
+ 'persists a pending terminal status at shutdown after starting with hooks %s',
+ async (statusHooksEnabled) => {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
+ const server = new AgentHookServer()
+ try {
+ await server.start({ userDataPath: dir, statusHooksEnabled })
+ server.setStatusHooksEnabled(false)
+ server.ingestTerminalStatus({
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'folder-1',
+ payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
+ })
+ expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
+ const statusPath = server.lastStatusPath
+ if (!statusPath) {
+ throw new Error('missing status persistence path')
+ }
+ server.stop()
+ expect(existsSync(statusPath)).toBe(true)
+ expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
+ paneKey: PANE,
+ worktreeId: 'folder-1',
+ payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
+ })
+ } finally {
+ server.stop()
+ rmSync(dir, { recursive: true, force: true })
+ }
+ }
+ )
+
+ it('enables and disables status without restarting the control listener', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
+ class IsolatedHookServer extends AgentHookServer {
+ constructor() {
+ super()
+ this._setOpenCodeBinderDepsForTests({
+ dbPath: () => join(dir, 'no-user-db'),
+ listSessions: async () => [],
+ listPanes: () => [],
+ sweep: async () => []
+ })
+ }
+ }
+ const server = new IsolatedHookServer()
+ try {
+ await server.start({ userDataPath: dir, statusHooksEnabled: false })
+ const endpoint = server.endpointFilePath
+ if (!endpoint) {
+ throw new Error('missing control endpoint')
+ }
+ const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
+ const post = (path: string) =>
+ fetch(`http://127.0.0.1:${coords.port}${path}`, {
+ method: 'POST',
+ headers: { 'x-orca-agent-hook-token': coords.token },
+ body: '{}'
+ })
+ expect((await post('/hook/opencode')).status).toBe(404)
+ await server.start({ statusHooksEnabled: true })
+ expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
+ expect((await post('/hook/opencode')).status).toBe(204)
+ server.setStatusHooksEnabled(false)
+ expect(server.buildPtyEnv()).toEqual({})
+ expect((await post('/hook/opencode')).status).toBe(404)
+ await server.start()
+ expect((await post('/hook/opencode')).status).toBe(404)
+ server.setStartupPromptClaimListener(
+ () => 'pending',
+ () => {}
+ )
+ expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
+ allowed: false,
+ pending: true
+ })
+ server.setStatusHooksEnabled(true)
+ expect((await post('/hook/opencode')).status).toBe(204)
+ expect(server.endpointFilePath).toBe(endpoint)
+ expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
+ } finally {
+ server.stop()
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
+ const server = new AgentHookServer()
+ try {
+ await server.start({ userDataPath: dir, statusHooksEnabled: false })
+ expect(server.buildPtyEnv()).toEqual({})
+ const statusPath = server.lastStatusPath
+ if (!statusPath) {
+ throw new Error('missing status persistence path')
+ }
+ writeFileSync(statusPath, 'existing status must survive control-only shutdown')
+ const endpoint = server.endpointFilePath
+ if (!endpoint) {
+ throw new Error('missing control endpoint')
+ }
+ const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
+ const post = (path: string, body: string, token = coords.token) =>
+ fetch(`http://127.0.0.1:${coords.port}${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
+ body
+ })
+ expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
+ expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
+ allowed: false
+ })
+ const clear = vi.fn()
+ const claim = vi.fn(() => true)
+ server.setStartupPromptClaimListener(claim, clear)
+ expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
+ allowed: true
+ })
+ expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
+ allowed: false
+ })
+ expect(claim).toHaveBeenCalledTimes(1)
+ expect((await post('/hook/opencode', '{}')).status).toBe(404)
+ expect((await post('/statusline/claude', '{}')).status).toBe(404)
+ server.stop()
+ expect(clear).toHaveBeenCalledTimes(1)
+ expect(readFileSync(statusPath, 'utf8')).toBe(
+ 'existing status must survive control-only shutdown'
+ )
+ } finally {
+ server.stop()
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts
index 37c80087bdb..e8d5a8e357b 100644
--- a/src/main/agent-hooks/server-transport-interference.test.ts
+++ b/src/main/agent-hooks/server-transport-interference.test.ts
@@ -2,9 +2,11 @@
// short of its own Content-Length. The listener fails open on every request error, so the only
// way this stays diagnosable is if the truncation is classified before it is swallowed.
import { connect } from 'node:net'
+import { ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
import { AgentHookServer } from './server'
+import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
async function postTruncatedHook(
port: number,
@@ -40,11 +42,15 @@ async function postTruncatedHook(
}
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
-async function postStalledHook(port: number, token: string): Promise {
+async function postStalledHook(
+ port: number,
+ token: string,
+ pathname = '/hook/claude'
+): Promise {
const socket = connect({ port, host: '127.0.0.1' })
await new Promise((resolve) => socket.on('connect', () => resolve()))
socket.write(
- `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
+ `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
)
await new Promise((resolve) => {
socket.on('close', () => resolve())
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
expect(reports).toHaveLength(1)
}, 20_000)
+ it('classifies an interrupted startup claim as retryable without consuming it', async () => {
+ const { server, port, token, reports } = await startServer()
+ const claim = vi.fn(() => true)
+ server.setStartupPromptClaimListener(claim, () => {})
+ const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
+ try {
+ await postTruncatedHook(port, token, {
+ pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
+ sentBytes: '{"nonce":',
+ announcedLength: 1000
+ })
+ // The reset peer cannot receive this response; observe the real handler's classification.
+ expect(writeHead.mock.calls).toEqual([[503]])
+ expect(claim).not.toHaveBeenCalled()
+ expect(reports).toEqual([])
+ } finally {
+ writeHead.mockRestore()
+ }
+ })
+
+ it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
+ const { server, port, token, reports } = await startServer()
+ const claim = vi.fn(() => true)
+ server.setStartupPromptClaimListener(claim, () => {})
+ const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
+ try {
+ await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
+ expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
+ expect(claim).not.toHaveBeenCalled()
+ expect(reports).toEqual([])
+ } finally {
+ writeHead.mockRestore()
+ }
+ }, 30_000)
+
it('never reports for POSTs that deliver their whole body', async () => {
const { port, token, reports } = await startServer()
diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts
index 80c81f09142..825b51b8a63 100644
--- a/src/main/agent-hooks/server/server-lifecycle.ts
+++ b/src/main/agent-hooks/server/server-lifecycle.ts
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
-import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
import { trackEmptyPaneKeyHook } from './server-transport-rules'
-import { AgentHookServerRuntimeEnv } from './server-runtime-env'
+import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
+import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
-export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
+export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
/** Start the loopback listener after hydration and spool replay have settled. */
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
+ statusHooksEnabled?: boolean
}): Promise {
if (this.server) {
+ if (options?.statusHooksEnabled !== undefined) {
+ this.setStatusHooksEnabled(options.statusHooksEnabled)
+ }
return
}
+ this.statusHooksEnabled = options?.statusHooksEnabled !== false
if (options?.env) {
this.env = options.env
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
- if (!this.ownerStateInitialized) {
- // Why: hydrate before binding the listener so an early hook POST runs against a populated map.
- if (this.lastStatusFilePath) {
- this.hydrateLastStatusFromDisk()
- }
- this.captureHydratedAuthorityCommitments()
- // Drain before binding the listener so replay cannot race a live hook during startup.
- if (this.endpointDir) {
- const replayedPaneKeys = new Set()
- drainAgentHookSpool({
- endpointDir: this.endpointDir,
- getPersistedLaunchTokenHash: (paneKey) =>
- this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
- ingest: (record: SpoolRecord) => {
- this.ingestSpoolRecord(record)
- replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
- }
- })
- // Why: the owner may have died while Orca was down; check each replayed pane once.
- for (const paneKey of replayedPaneKeys) {
- void this.checkAgentPresence(paneKey)
- }
- }
- this.ownerStateInitialized = true
+ if (this.statusHooksEnabled) {
+ this.initializeStatusHookOwner()
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => {
if (req.method !== 'POST') {
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
try {
const body = await readRequestBody(req)
+ if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
+ res.writeHead(200, { 'content-type': 'application/json' })
+ const claim = this.onStartupPromptClaim?.(body)
+ res.end(
+ JSON.stringify({
+ allowed: claim === true,
+ ...(claim === 'pending' ? { pending: true } : {})
+ })
+ )
+ return
+ }
+ if (!this.statusHooksEnabled) {
+ res.writeHead(404)
+ res.end()
+ return
+ }
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
res.writeHead(204)
res.end()
} catch (error) {
+ if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
+ if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
+ res.writeHead(503)
+ res.end()
+ return
+ }
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end('{"allowed":false}')
+ return
+ }
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
// this is the one failure mode that silently stops status for every runtime at once.
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.rollbackTransportStart()
throw error
}
- this.startOpenCodeBinderLoop()
+ if (this.statusHooksEnabled) {
+ this.startOpenCodeBinderLoop()
+ }
}
private rollbackTransportStart(): void {
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
}
stop(): void {
- // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
- this.flushStatusPersistSync()
+ // Terminal status may still have a pending write while hook ingress is disabled.
+ if (this.statusHooksEnabled || this.statusPersistTimer) {
+ this.flushStatusPersistSync()
+ }
this.stopOpenCodeBinderLoop()
this.stopTmuxStatus()
this.rollbackTransportStart()
this.env = 'production'
this.onAgentStatus = null
this.onClaudeStatusLine = null
+ this.clearStartupPromptClaims?.()
+ this.clearStartupPromptClaims = null
+ this.onStartupPromptClaim = null
this.onPaneStatusCleared = null
this.onTransportInterference = null
this.transportInterference.reset()
diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts
index 1de09c3ebdf..14b78262734 100644
--- a/src/main/agent-hooks/server/server-listeners.ts
+++ b/src/main/agent-hooks/server/server-listeners.ts
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
+ setStartupPromptClaimListener(
+ listener: (body: unknown) => boolean | 'pending',
+ clear: () => void
+ ): void {
+ this.onStartupPromptClaim = listener
+ this.clearStartupPromptClaims = clear
+ }
+
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts
index 7bf7eaaee30..2f94bdd4b94 100644
--- a/src/main/agent-hooks/server/server-runtime-env.ts
+++ b/src/main/agent-hooks/server/server-runtime-env.ts
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
buildPtyEnv(): Record {
- if (this.port <= 0 || !this.token) {
+ if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
return {}
}
const env: Record = {
diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts
index f429c0e6350..172eaeaf1ef 100644
--- a/src/main/agent-hooks/server/server-state.ts
+++ b/src/main/agent-hooks/server/server-state.ts
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
+ protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
+ protected clearStartupPromptClaims: (() => void) | null = null
+ protected statusHooksEnabled = true
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set()
protected statusDropListeners = new Set()
diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts
new file mode 100644
index 00000000000..15a43182ab2
--- /dev/null
+++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts
@@ -0,0 +1,52 @@
+import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
+import { AgentHookServerRuntimeEnv } from './server-runtime-env'
+
+export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
+ setStatusHooksEnabled(enabled: boolean): void {
+ if (enabled === this.statusHooksEnabled) {
+ return
+ }
+ if (!enabled) {
+ this.flushStatusPersistSync()
+ this.stopOpenCodeBinderLoop()
+ for (const timer of this.assistantMessageRetryTimers.values()) {
+ clearTimeout(timer)
+ }
+ this.assistantMessageRetryTimers.clear()
+ this.clearAllTranscriptPolls()
+ }
+ this.statusHooksEnabled = enabled
+ if (enabled && this.server) {
+ this.initializeStatusHookOwner()
+ this.startOpenCodeBinderLoop()
+ }
+ }
+
+ protected initializeStatusHookOwner(): void {
+ if (!this.ownerStateInitialized) {
+ // Why: hydrate before binding the listener so an early hook POST runs against a populated map.
+ if (this.lastStatusFilePath) {
+ this.hydrateLastStatusFromDisk()
+ }
+ this.captureHydratedAuthorityCommitments()
+ // Drain before binding the listener so replay cannot race a live hook during startup.
+ if (this.endpointDir) {
+ const replayedPaneKeys = new Set()
+ drainAgentHookSpool({
+ endpointDir: this.endpointDir,
+ getPersistedLaunchTokenHash: (paneKey) =>
+ this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
+ ingest: (record: SpoolRecord) => {
+ this.ingestSpoolRecord(record)
+ replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
+ }
+ })
+ // Why: the owner may have died while Orca was down; check each replayed pane once.
+ for (const paneKey of replayedPaneKeys) {
+ void this.checkAgentPresence(paneKey)
+ }
+ }
+ this.ownerStateInitialized = true
+ }
+ }
+}
diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts
index e956494fc76..e0052aa8b45 100644
--- a/src/main/agent-trust-presets.test.ts
+++ b/src/main/agent-trust-presets.test.ts
@@ -6,6 +6,7 @@ import {
readFileSync,
realpathSync,
rmSync,
+ statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
}
})
+ it('keeps a token-bearing config.json owner-only', () => {
+ if (process.platform === 'win32') {
+ return
+ }
+ const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
+ const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
+ // Why: a restrictive runner umask would mask a dropped mode.
+ const originalUmask = process.umask(0o022)
+ try {
+ mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
+ writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
+ mode: 0o600
+ })
+ markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
+ expect(statSync(configPath).mode & 0o777).toBe(0o600)
+ expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
+ } finally {
+ process.umask(originalUmask)
+ rmSync(workspace, { recursive: true, force: true })
+ }
+ })
+
+ it('creates a missing config.json owner-only', () => {
+ if (process.platform === 'win32') {
+ return
+ }
+ const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
+ const originalUmask = process.umask(0o022)
+ try {
+ markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
+ const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
+ expect(statSync(configPath).mode & 0o777).toBe(0o600)
+ } finally {
+ process.umask(originalUmask)
+ rmSync(workspace, { recursive: true, force: true })
+ }
+ })
+
it('preserves existing config keys and dedups already-trusted folders', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const realpath = realpathSync(workspace)
diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts
index 2d52ee005e4..eac0591728b 100644
--- a/src/main/agent-trust-presets.ts
+++ b/src/main/agent-trust-presets.ts
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
- writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
+ // Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
+ writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
}
/**
diff --git a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts
index a8c1ba96e75..d04cf73f667 100644
--- a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts
+++ b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts
@@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)(
},
transport
)
- ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
- expect(service.search).not.toHaveBeenCalled()
- expect(service.reconcile).not.toHaveBeenCalled()
+ ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
+ expect(service.search).toHaveBeenCalledExactlyOnceWith(
+ { query: 'proof', limit: 20, freshness: 'wait-until-current' },
+ { kind: 'resolved', paths: [''] }
+ )
+ expect(service.reconcile).toHaveBeenCalledTimes(1)
}
)
@@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)(
)
test.each(['runtime', 'relay'] as const)(
- 'does not query or reconcile an unsupported-only filter on %s',
+ 'keeps an unsupported-only filter narrow through normal service checks on %s',
async (transport) => {
const request = {
query: 'proof',
@@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)(
{ ...request, filters: { agents: [...request.filters.agents] } },
transport
)
- ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
- expect(service.search).not.toHaveBeenCalled()
+ ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
+ expect(service.search).toHaveBeenCalledExactlyOnceWith(
+ {
+ query: 'proof',
+ limit: 20,
+ filters: { agents: ['jcode'] },
+ freshness: 'wait-until-current'
+ },
+ { kind: 'resolved', paths: [''] }
+ )
expect(service.status).not.toHaveBeenCalled()
- expect(service.reconcile).not.toHaveBeenCalled()
+ expect(service.reconcile).toHaveBeenCalledTimes(1)
for (const reason of ['disabled', 'not-ready'] as const) {
service.search.mockResolvedValue({ kind: 'unavailable', reason })
expect(
- await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport)
+ await searchSessionService(
+ { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
+ transport
+ )
).toEqual({ kind: 'unavailable', reason })
}
}
diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts
index d8fb2c081bc..8e172e5f2c1 100644
--- a/src/main/ai-vault-search/session-search-service-registry.test.ts
+++ b/src/main/ai-vault-search/session-search-service-registry.test.ts
@@ -86,8 +86,8 @@ describe('session search service registry', () => {
{ query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } },
transport
)
- ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
- expect(service.search).toHaveBeenCalledTimes(1)
+ ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
+ expect(service.search).toHaveBeenCalledTimes(2)
expect(service.reconcile).not.toHaveBeenCalled()
const client = createSessionSearchClient(
(method, request) =>
@@ -114,6 +114,24 @@ describe('session search service registry', () => {
)
expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS)
})
+ it.each(['disabled', 'not-ready'] as const)(
+ 'preserves %s precedence for empty negotiated host intersections',
+ async (reason) => {
+ const service = fakeSearchService()
+ service.search.mockResolvedValue({ kind: 'unavailable', reason })
+ setSessionSearchService(service)
+ expect(
+ await searchSessionService(
+ { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
+ 'runtime'
+ )
+ ).toEqual({ kind: 'unavailable', reason })
+ expect(service.search).toHaveBeenCalledExactlyOnceWith(
+ { query: 'proof', limit: 20, filters: { agents: ['jcode'] } },
+ { kind: 'resolved', paths: [''] }
+ )
+ }
+ )
it('waits for reconcile before search, and clears its timeout', async () => {
vi.useFakeTimers()
const service = fakeSearchService()
diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts
index 2f339185e64..02c448e11da 100644
--- a/src/main/ai-vault-search/session-search-service-registry.ts
+++ b/src/main/ai-vault-search/session-search-service-registry.ts
@@ -52,11 +52,8 @@ export async function searchSessionService(
supportsJcodeHistory
}
)
- if (compatibleAgents.length === 0) {
- return { kind: 'unavailable', reason: 'unsupported-agent' }
- }
const compatibleRequest =
- compatibleAgents.length === agents.length
+ compatibleAgents.length === 0 || compatibleAgents.length === agents.length
? request
: {
...request,
@@ -68,12 +65,16 @@ export async function searchSessionService(
const hostScope = within
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
: undefined
+ const retrievalScope =
+ compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
+ ? { kind: 'resolved' as const, paths: [''] }
+ : hostScope
const freshness =
request.freshness === 'wait-until-current'
? await reconcileWithin(current, freshnessTimeoutMs)
: false
const result = AiVaultSearchResponseSchema.parse(
- await current.search(compatibleRequest, hostScope)
+ await current.search(compatibleRequest, retrievalScope)
)
if (result.kind !== 'results') {
return result
@@ -81,6 +82,7 @@ export async function searchSessionService(
const { debug, ...fields } = result
return {
...fields,
+ ...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}),
hits: result.hits
.filter((hit) => compatibleAgents.includes(hit.agent))
.map((hit) => redactForTransport(hit, transport)),
diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts
new file mode 100644
index 00000000000..3ab4053b303
--- /dev/null
+++ b/src/main/ai-vault/session-document-stream-contract.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it } from 'vitest'
+import { readStreamedSessionDocument } from './session-document-stream'
+
+async function* bytes(content: string, chunkSize = 7): AsyncGenerator {
+ const buffer = Buffer.from(content)
+ for (let offset = 0; offset < buffer.length; offset += chunkSize) {
+ yield buffer.subarray(offset, offset + chunkSize)
+ }
+}
+
+function read(
+ content: string,
+ overrides: Partial>[0]> = {}
+) {
+ return readStreamedSessionDocument({
+ bytes: bytes(content),
+ arrayKey: 'messages',
+ fields: ['id'],
+ objectFields: { agent: ['model'] },
+ create: (): unknown[] => [],
+ consume: (state, value) => {
+ state.push(value)
+ },
+ ...overrides
+ })
+}
+
+describe('streamed session document contracts', () => {
+ it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
+ 'a later messages value %s clears an earlier fold',
+ async (last) => {
+ expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
+ }
+ )
+
+ it('replaces a failed earlier array and continues with its successor', async () => {
+ const consume = (state: unknown[], value: unknown): void => {
+ if (value === 'bad') {
+ throw new Error('discarded failure')
+ }
+ state.push(value)
+ }
+ expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
+ record: {},
+ state: [2, 3]
+ })
+ expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
+ record: {},
+ state: []
+ })
+ })
+
+ it('keeps the first consumer failure unless a later array replaces it', async () => {
+ const failure = new Error('consumer failed')
+ let calls = 0
+ await expect(
+ read('{"messages":[1,2]}', {
+ consume: () => {
+ calls++
+ throw failure
+ }
+ })
+ ).rejects.toBe(failure)
+ expect(calls).toBe(1)
+ })
+
+ it('gives malformed trailing JSON precedence over a consumer failure', async () => {
+ await expect(
+ read('{"messages":[1]} trailing', {
+ consume: () => {
+ throw new Error('consumer')
+ }
+ })
+ ).rejects.toBeInstanceOf(SyntaxError)
+ })
+
+ it('keeps projected object resets and full-field overlap precedence', async () => {
+ expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
+ record: { agent: {} },
+ state: []
+ })
+ expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
+ record: { agent: { model: 'm', extra: [1, 2] } },
+ state: []
+ })
+ expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
+ record: { messages: {} },
+ state: [1, 2]
+ })
+ expect(
+ await read('{"messages":{"model":"m","ignored":1}}', {
+ objectFields: { messages: ['model'] }
+ })
+ ).toEqual({ record: { messages: { model: 'm' } }, state: [] })
+ })
+
+ it('preserves selected prototype keys as own properties', async () => {
+ const content =
+ '{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
+ const parsed = await read(content)
+ expect(parsed?.record).toEqual(JSON.parse(content))
+ expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
+ expect(Object.prototype).not.toHaveProperty('polluted')
+ })
+
+ it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
+ 'rejects malformed input %j',
+ async (content) => {
+ await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
+ }
+ )
+
+ it.each(['null', '[]', '123', '"text"'])(
+ 'does not publish a nonobject document %s',
+ async (content) => {
+ expect(await read(content)).toBeNull()
+ }
+ )
+
+ it('validates discarded subtrees and closes their source on malformed input', async () => {
+ let closed = false
+ async function* malformed() {
+ try {
+ yield Buffer.from('{"ignored":[{"deep":1},]}')
+ throw new Error('must not request another chunk')
+ } finally {
+ closed = true
+ }
+ }
+ await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
+ expect(closed).toBe(true)
+ })
+
+ it('preserves source failure and closes the source even after a consumer failure', async () => {
+ const failure = new Error('disk failure')
+ let closed = false
+ async function* failing() {
+ try {
+ yield Buffer.from('{"messages":[1]')
+ throw failure
+ } finally {
+ closed = true
+ }
+ }
+ await expect(
+ read('', {
+ bytes: failing(),
+ consume: () => {
+ throw new Error('consumer')
+ }
+ })
+ ).rejects.toBe(failure)
+ expect(closed).toBe(true)
+ })
+
+ it('preserves escaped astral text across large and byte-sized chunks', async () => {
+ const value = `${'x'.repeat(65530)}日本語😀\\literal`
+ const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
+ for (const size of [1, 65536, content.length * 4]) {
+ expect(await read(content, { bytes: bytes(content, size) })).toEqual({
+ record: {},
+ state: [value, '\ud800', '\udc00']
+ })
+ }
+ })
+})
diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts
index 4d12ab0fcc2..4fcf24f7a55 100644
--- a/src/main/ai-vault/session-document-stream.ts
+++ b/src/main/ai-vault/session-document-stream.ts
@@ -1,6 +1,7 @@
import { StringDecoder } from 'node:string_decoder'
-import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
+import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
+import { createJsonTokenReader } from '../../shared/json-token-reader'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
/** Fold one root array while retaining only the root fields the agent parser uses. */
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument(args: {
consume: (state: T, value: unknown) => void
signal?: AbortSignal
}): Promise<{ record: Record; state: T } | null> {
- const parser = new JSONParser({
- paths: [
- ...args.fields.map((field) => `$.${field}`),
- ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
- fields.map((field) => `$.${root}.${field}`)
- ),
- ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
- ],
- keepStack: false,
- stringBufferSize: 64 * 1024
- })
const record: Record = Object.create(null)
const fields = new Set(args.fields)
- let depth = 0
- let expectingRootKey = false
- parser.onToken = ({ token, value }) => {
- if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
- if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
- record[value] = Object.create(null)
- }
- expectingRootKey = false
- }
- if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
- if (depth === 0 && token === TokenType.LEFT_BRACE) {
- expectingRootKey = true
- }
- depth++
- } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
- depth--
- } else if (token === TokenType.COMMA && depth === 1) {
- expectingRootKey = true
- }
- }
+ const objectFields = new Map(
+ Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
+ )
+ const foldedArray = Symbol('folded session array')
let state = args.create()
- let currentArray: unknown = null
let consumeFailure: { error: unknown } | undefined
+ let inFoldedArray = false
+ const reset = (): void => {
+ state = args.create()
+ consumeFailure = undefined
+ }
+ const retain = (path: (string | number)[]): boolean => {
+ const [root, child] = path
+ return (
+ typeof root === 'string' &&
+ ((root !== args.arrayKey && fields.has(root)) ||
+ (inFoldedArray && root === args.arrayKey && path.length >= 2) ||
+ (typeof child === 'string' && objectFields.get(root)?.has(child) === true))
+ )
+ }
+ // Every discarded container needs a rule; dropping only its parent still builds large children.
+ const discard = {
+ filter: (path: (string | number)[]) => !retain(path),
+ create: () => null,
+ add: () => {}
+ }
+ const assembler = new FlexAssembler({
+ maxDepth: Infinity,
+ objectRules: [
+ objectRule>({
+ filter: (path) => path.length === 0,
+ create: () => record,
+ add: (target, key, value) => {
+ if (key === args.arrayKey) {
+ if (value !== foldedArray) {
+ reset()
+ }
+ } else if (fields.has(key)) {
+ target[key] = value
+ }
+ }
+ }),
+ objectRule>({
+ filter: (path) =>
+ path.length === 1 &&
+ typeof path[0] === 'string' &&
+ objectFields.has(path[0]) &&
+ (!fields.has(path[0]) || path[0] === args.arrayKey),
+ create: (path) => {
+ const projected: Record = Object.create(null)
+ record[String(path[0])] = projected
+ return projected
+ },
+ add: (target, key, value) => {
+ const root = assembler.path[0]
+ if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
+ target[key] = value
+ }
+ }
+ }),
+ discard
+ ],
+ arrayRules: [
+ arrayRule({
+ filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
+ create: () => {
+ reset()
+ inFoldedArray = true
+ return null
+ },
+ add: (_target, value) => {
+ if (!consumeFailure) {
+ try {
+ args.consume(state, value)
+ } catch (error) {
+ consumeFailure = { error }
+ }
+ }
+ },
+ finalize: () => {
+ inFoldedArray = false
+ return foldedArray
+ }
+ }),
+ discard
+ ]
+ })
+ const parser = createJsonTokenReader((token) => {
+ if (
+ token.name === 'keyValue' &&
+ assembler.depth === 1 &&
+ !assembler.isArray &&
+ objectFields.has(token.value)
+ ) {
+ record[token.value] = Object.create(null)
+ }
+ assembler.consume(token)
+ })
const decoder = new StringDecoder('utf8')
let objectRoot: boolean | undefined
- parser.onValue = ({ key, value, parent, stack }) => {
- if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
- if (parent !== currentArray) {
- state = args.create()
- consumeFailure = undefined
- currentArray = parent
- }
- if (!consumeFailure) {
- try {
- args.consume(state, value)
- } catch (error) {
- consumeFailure = { error }
- }
- }
- // The parser's array cursor is independent of retained array slots.
- parent.pop()
- } else if (
- stack.length === 2 &&
- typeof stack[1].key === 'string' &&
- typeof key === 'string' &&
- parent &&
- !Array.isArray(parent)
- ) {
- const root = stack[1].key
- const projected = record[root]
- if (
- Object.hasOwn(args.objectFields ?? {}, root) &&
- args.objectFields?.[root]?.includes(key) &&
- projected &&
- typeof projected === 'object'
- ) {
- Reflect.set(projected, key, value)
- }
- } else if (stack.length === 1 && typeof key === 'string') {
- if (key === args.arrayKey) {
- if (value !== currentArray || !Array.isArray(value)) {
- state = args.create()
- consumeFailure = undefined
- }
- currentArray = null
- } else if (fields.has(key)) {
- record[key] = value
- }
- if (parent && typeof parent === 'object') {
- Reflect.deleteProperty(parent, key)
- }
- }
- }
for await (const chunk of args.bytes) {
throwIfAiVaultScanCancelled(args.signal)
if (objectRoot === undefined) {
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument(args: {
objectRoot = first === 123
}
}
- parseJson(() => parser.write(decoder.write(chunk)))
+ parser.write(decoder.write(chunk))
await yieldToEventLoop()
}
const tail = decoder.end()
if (tail) {
- parseJson(() => parser.write(tail))
- }
- if (objectRoot === undefined) {
- throw new SyntaxError('Unexpected end of JSON input')
- }
- if (!parser.isEnded) {
- parseJson(() => parser.end(), true)
+ parser.write(tail)
}
+ parser.end()
throwIfAiVaultScanCancelled(args.signal)
if (consumeFailure) {
throw consumeFailure.error
}
return objectRoot ? { record, state } : null
}
-
-function parseJson(run: () => void, ending = false): void {
- try {
- run()
- } catch (error) {
- if (
- error instanceof TokenizerError ||
- error instanceof TokenParserError ||
- (ending && error instanceof Error)
- ) {
- throw new SyntaxError(error.message)
- }
- throw error
- }
-}
diff --git a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts
index 9e8511967f2..9b41f700b24 100644
--- a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts
+++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts
@@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => {
throw new Error('Expected the bounded native page')
}
expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
+ expect(page.items[0]?.message.role).toBe('system')
expect(page.items[0]?.message.blocks).toEqual([
- { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
+ {
+ type: 'text',
+ text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
+ presentation: 'history-item-too-large'
+ }
])
const repeat = await client.readNativeChat(args)
expect(repeat).toEqual(page)
diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts
index f6d3398c679..9fbd8ab183f 100644
--- a/src/main/antigravity/native-account-store.test.ts
+++ b/src/main/antigravity/native-account-store.test.ts
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
const service = new AntigravityAccountService(store, h.backend)
h.setNative(paddedCredential('new-account', 60000))
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
- expect(readFileSync(path)).toEqual(before)
+ expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
h.setNative(paddedCredential('large-51', 65000))
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
- expect(readFileSync(path)).toEqual(before)
+ expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
})
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
'Protected secret storage'
)
expect(() => store.read()).toThrow('Protected secret storage')
- expect(readFileSync(path)).toEqual(before)
+ expect(readFileSync(path).equals(before)).toBe(true)
}
)
diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts
index 2d9aa5e50fe..a38cead49c9 100644
--- a/src/main/browser/browser-manager-annotation-bridge.test.ts
+++ b/src/main/browser/browser-manager-annotation-bridge.test.ts
@@ -51,6 +51,28 @@ const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
token: 'annotationviewporttoken'
}
+const MARKER_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
+ ...BRIDGE_OPTIONS,
+ markers: [
+ {
+ id: 'historical-document-marker',
+ index: 2,
+ isFixed: false,
+ rectPage: { x: 80, y: 120, width: 100, height: 40 },
+ rectViewport: { x: 80, y: 120, width: 100, height: 40 }
+ }
+ ]
+}
+
+function deferGuestInjection(guest: Record): () => void {
+ let release = (): void => {}
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ guest.executeJavaScriptInIsolatedWorld = vi.fn(() => gate)
+ return release
+}
+
function registerPage(pageId: string, guest: Record): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
@@ -141,6 +163,161 @@ describe('browserManager.setAnnotationViewportBridge', () => {
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
+ it.each([false, true])(
+ 'skips retired queued markers after invalidation (replacement guest: %s)',
+ async (replaceGuest) => {
+ const { guest: firstGuest } = makeGuest(5201)
+ const release = deferGuestInjection(firstGuest)
+ registerPage('tab-retired-markers', firstGuest)
+ const resolveGuest = resolveFromRegistry('tab-retired-markers')
+ const inFlight = browserManager.setAnnotationViewportBridge(
+ 'tab-retired-markers',
+ BRIDGE_OPTIONS,
+ resolveGuest
+ )
+ await flushViewportOps()
+ const retired = browserManager.setAnnotationViewportBridge(
+ 'tab-retired-markers',
+ MARKER_OPTIONS,
+ resolveGuest
+ )
+ const { guest: replacementGuest } = makeGuest(5202)
+ if (replaceGuest) {
+ registerPage('tab-retired-markers', replacementGuest)
+ }
+ const cleared = browserManager.setAnnotationViewportBridge(
+ 'tab-retired-markers',
+ { ...BRIDGE_OPTIONS, enabled: false },
+ resolveGuest
+ )
+
+ release()
+ await expect(inFlight).resolves.toBe(true)
+ await expect(retired).resolves.toBe(false)
+ await expect(cleared).resolves.toBe(true)
+ const currentGuest = replaceGuest ? replacementGuest : firstGuest
+ expect(currentGuest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalledWith(
+ expect.any(Number),
+ [expect.objectContaining({ code: expect.stringContaining('historical-document-marker') })],
+ false
+ )
+ expect(currentGuest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
+ expect.any(Number),
+ [expect.objectContaining({ code: expect.stringContaining('const enabled = false;') })],
+ false
+ )
+ }
+ )
+
+ it('keeps an in-flight injection and applies the latest fresh markers and token', async () => {
+ const { guest } = makeGuest(5203)
+ const release = deferGuestInjection(guest)
+ const resolveGuest = resolveFromRegistry('tab-fresh-markers')
+ registerPage('tab-fresh-markers', guest)
+ const inFlight = browserManager.setAnnotationViewportBridge(
+ 'tab-fresh-markers',
+ MARKER_OPTIONS,
+ resolveGuest
+ )
+ await flushViewportOps()
+ const cleared = browserManager.setAnnotationViewportBridge(
+ 'tab-fresh-markers',
+ { ...BRIDGE_OPTIONS, enabled: false },
+ resolveGuest
+ )
+ const fresh = browserManager.setAnnotationViewportBridge(
+ 'tab-fresh-markers',
+ {
+ ...MARKER_OPTIONS,
+ token: 'remintedpreviewtoken',
+ markers: MARKER_OPTIONS.markers.map((marker) => ({ ...marker, id: 'fresh-preview-marker' }))
+ },
+ resolveGuest
+ )
+
+ release()
+ await expect(inFlight).resolves.toBe(true)
+ await expect(cleared).resolves.toBe(false)
+ await expect(fresh).resolves.toBe(true)
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2)
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
+ expect.any(Number),
+ [
+ expect.objectContaining({
+ code: expect.stringContaining('fresh-preview-marker')
+ })
+ ],
+ false
+ )
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
+ expect.any(Number),
+ [expect.objectContaining({ code: expect.stringContaining('remintedpreviewtoken') })],
+ false
+ )
+ })
+
+ it('continues the queue after an in-flight injection fails', async () => {
+ const { guest } = makeGuest(5204)
+ let rejectInjection = (): void => {}
+ const gate = new Promise((_resolve, reject) => {
+ rejectInjection = () => reject(new Error('retired document'))
+ })
+ guest.executeJavaScriptInIsolatedWorld = vi
+ .fn()
+ .mockReturnValueOnce(gate)
+ .mockResolvedValue(true)
+ registerPage('tab-failed-injection', guest)
+ const resolveGuest = resolveFromRegistry('tab-failed-injection')
+ const inFlight = browserManager.setAnnotationViewportBridge(
+ 'tab-failed-injection',
+ MARKER_OPTIONS,
+ resolveGuest
+ )
+ await flushViewportOps()
+ const cleared = browserManager.setAnnotationViewportBridge(
+ 'tab-failed-injection',
+ { ...BRIDGE_OPTIONS, enabled: false },
+ resolveGuest
+ )
+
+ rejectInjection()
+ await expect(inFlight).resolves.toBe(false)
+ await expect(cleared).resolves.toBe(true)
+ await expect(
+ browserManager.setAnnotationViewportBridge(
+ 'tab-failed-injection',
+ BRIDGE_OPTIONS,
+ resolveGuest
+ )
+ ).resolves.toBe(true)
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(3)
+ })
+
+ it('does not run a queued marker request after page teardown removes its chain', async () => {
+ const { guest } = makeGuest(5205)
+ const release = deferGuestInjection(guest)
+ registerPage('tab-closed-queue', guest)
+ const resolveGuest = resolveFromRegistry('tab-closed-queue')
+ const inFlight = browserManager.setAnnotationViewportBridge(
+ 'tab-closed-queue',
+ BRIDGE_OPTIONS,
+ resolveGuest
+ )
+ await flushViewportOps()
+ const retired = browserManager.setAnnotationViewportBridge(
+ 'tab-closed-queue',
+ MARKER_OPTIONS,
+ resolveGuest
+ )
+ browserManager.unregisterGuest('tab-closed-queue')
+
+ release()
+ await expect(inFlight).resolves.toBe(true)
+ await expect(retired).resolves.toBe(false)
+ expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
+ expect(browserManager.getGuestWebContentsId('tab-closed-queue')).toBeNull()
+ })
+
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts
index 64e50e5ea48..6f7e7c2b281 100644
--- a/src/main/browser/browser-manager-viewport.ts
+++ b/src/main/browser/browser-manager-viewport.ts
@@ -77,7 +77,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
- .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
+ .then(() => {
+ // A newer document invalidation retires geometry still waiting in the queue.
+ if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) !== next) {
+ return false
+ }
+ return this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)
+ })
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts
index 2ba58138c24..613c2694ac7 100644
--- a/src/main/claude/claude-child-work-evidence-retention.test.ts
+++ b/src/main/claude/claude-child-work-evidence-retention.test.ts
@@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeChildWorkDecoder } from './claude-child-work-decoder'
import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence'
+import { ClaudePromptRegistry } from './claude-prompt-registry'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import {
claudeToolResults,
@@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => {
translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 })
const join = vi.spyOn(Array.prototype, 'join')
const evidence = drainClaudeChildWork(
- { childWork: new ClaudeChildWorkDecoder(), translator },
+ {
+ childWork: new ClaudeChildWorkDecoder(),
+ prompts: new ClaudePromptRegistry(),
+ translator
+ },
message,
1_000
)
diff --git a/src/main/claude/claude-real-cli-test-gate.test.ts b/src/main/claude/claude-real-cli-test-gate.test.ts
index 508fd6af08e..df9b0ad936a 100644
--- a/src/main/claude/claude-real-cli-test-gate.test.ts
+++ b/src/main/claude/claude-real-cli-test-gate.test.ts
@@ -46,6 +46,25 @@ describe('resolveRealClaudeCliGate', () => {
expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]])
})
+ it('reads the final account report after the CLI configuration warning', () => {
+ const warning = [
+ 'Claude configuration file not found at: /home/dev/.claude/.claude.json',
+ 'A backup file exists at: /home/dev/.claude/backups/.claude.json.backup.123',
+ 'You can manually restore it by running: cp /home/dev/.claude/backups/.claude.json.backup.123 /home/dev/.claude/.claude.json',
+ ''
+ ].join('\n')
+ const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
+ args[0] === '--version'
+ ? { status: 0, stdout: '2.1.0\n' }
+ : { status: 0, stdout: warning + warning + JSON.stringify({ loggedIn: true }) }
+ )
+
+ expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({
+ skipReason: null,
+ authStatus: { loggedIn: true }
+ })
+ })
+
it('still skips when opted in but no claude binary answers', () => {
const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' }))
@@ -57,7 +76,14 @@ describe('resolveRealClaudeCliGate', () => {
it.each([
['a failed auth probe', { status: 1, stdout: '' }],
- ['unparseable auth output', { status: 0, stdout: 'not json' }]
+ ['unparseable auth output', { status: 0, stdout: 'not json' }],
+ ['malformed JSON', { status: 0, stdout: '{"loggedIn":' }],
+ ['trailing output', { status: 0, stdout: '{"loggedIn":true}\nnot json' }],
+ ['two JSON objects', { status: 0, stdout: '{"loggedIn":false}\n{"loggedIn":true}' }],
+ ['another JSON value before an object', { status: 0, stdout: 'true\n{"loggedIn":true}' }],
+ ['an array before an object', { status: 0, stdout: '[]\n{"loggedIn":true}' }],
+ ['a JSON array', { status: 0, stdout: '[{"loggedIn":true}]' }],
+ ['an unrelated JSON value', { status: 0, stdout: 'true' }]
])('runs with no account report after %s', (_label, authResult) => {
const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult
diff --git a/src/main/claude/claude-real-cli-test-gate.ts b/src/main/claude/claude-real-cli-test-gate.ts
index de25ef87bf4..1c6a609ed79 100644
--- a/src/main/claude/claude-real-cli-test-gate.ts
+++ b/src/main/claude/claude-real-cli-test-gate.ts
@@ -13,13 +13,26 @@ export type RealClaudeCliGate = {
}
function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null {
+ const start = stdout.search(/[[{]/)
+ if (start === -1) {
+ return null
+ }
+ // CLI warnings may precede the report, but another JSON value makes it ambiguous.
+ for (const line of stdout.slice(0, start).split('\n')) {
+ try {
+ JSON.parse(line)
+ return null
+ } catch {
+ continue
+ }
+ }
let parsed: unknown
try {
- parsed = JSON.parse(stdout)
+ parsed = JSON.parse(stdout.slice(start))
} catch {
return null
}
- if (!parsed || typeof parsed !== 'object') {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const status: RealClaudeAuthStatus = {}
diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts
index 869a8390808..a1540cf7720 100644
--- a/src/main/claude/claude-structured-journal-prompts.ts
+++ b/src/main/claude/claude-structured-journal-prompts.ts
@@ -93,7 +93,8 @@ export class ClaudeJournalPrompts {
const append = (identity: AgentJournalItemIdentity, body: ClaudeJournalPrompt['body']) => {
const options = { ...producer, turnScope }
if (this.deps.sink.tryAppendItem) {
- admitted &&= this.deps.sink.tryAppendItem(identity, body, options).accepted
+ const admission = this.deps.sink.tryAppendItem(identity, body, options)
+ admitted &&= admission.accepted
} else {
this.deps.sink.appendItem(identity, body, options)
}
diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts
index 93f38f221ee..145d0db7134 100644
--- a/src/main/claude/claude-structured-prompt-ownership.test.ts
+++ b/src/main/claude/claude-structured-prompt-ownership.test.ts
@@ -255,89 +255,129 @@ describe('Claude live prompt ownership', () => {
).toBe(false)
})
- it('rejects a grouped prompt batch without partially revising its first row', () => {
- const tombstones: string[] = []
- const appendTombstone = vi.fn(
- (identity: Parameters[0]) => {
- tombstones.push(agentJournalItemKey(identity))
- }
- )
- let rowAdmission = 0
- const tryAppendTombstone = vi.fn(
- (identity: Parameters[0]) => {
- rowAdmission += 1
- if (rowAdmission === 2) {
+ it.each([0, 1, null] as const)(
+ 'rejects a grouped prompt batch without partial revision after row %s admission refusal',
+ async (refusedAt) => {
+ const tombstones: string[] = []
+ const appendTombstone = vi.fn(
+ (identity: Parameters[0]) => {
+ tombstones.push(agentJournalItemKey(identity))
+ }
+ )
+ let rowAdmission = 0
+ const tryAppendTombstone = vi.fn(
+ (identity: Parameters[0]) => {
+ rowAdmission += 1
+ if (rowAdmission === 2) {
+ return { accepted: false as const, reason: 'backpressure' as const }
+ }
+ appendTombstone(identity)
+ return { accepted: true as const }
+ }
+ )
+ const tryAppendLifecycleBatch = vi.fn(
+ (
+ _settlementId: string,
+ mutations: Parameters<
+ NonNullable
+ >[1]
+ ) => {
+ expect(mutations[1]).toMatchObject({
+ kind: 'item',
+ body: { resolution: { state: 'cancelled' } }
+ })
return { accepted: false as const, reason: 'backpressure' as const }
}
- appendTombstone(identity)
- return { accepted: true as const }
+ )
+ const admittedRows = new Map()
+ let admissionAttempts = 0
+ const tryAppendItem = vi.fn(
+ (
+ identity: Parameters[0],
+ body: AgentJournalItemBody
+ ) => {
+ if (admissionAttempts++ === refusedAt) {
+ return { accepted: false as const, reason: 'backpressure' as const }
+ }
+ admittedRows.set(agentJournalItemKey(identity), body)
+ return { accepted: true as const }
+ }
+ )
+ const bindPromptItemId = vi.fn()
+ const written = vi.fn(async () => ({ ok: true as const }))
+ const prompts = new ClaudeJournalPrompts({
+ sink: {
+ appendItem: () => {},
+ tryAppendItem,
+ appendTombstone,
+ tryAppendTombstone,
+ tryAppendLifecycleBatch,
+ publish: () => {},
+ written
+ },
+ bindPromptItemId,
+ producerOf: () => ({ agentId: 'agent-grouped', producerKind: 'agent' }),
+ questionItems: (input) => {
+ const item = claudeQuestionItems(input)[0]
+ return item
+ ? [
+ {
+ ...item,
+ identity: { provider: 'orca', clientMessageId: 'group:first' }
+ },
+ {
+ ...item,
+ identity: { provider: 'orca', clientMessageId: 'group:second' }
+ }
+ ]
+ : []
+ },
+ turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
+ })
+ const prompt: ClaudePendingPrompt = {
+ requestId: 'grouped-request',
+ promptKey: 'grouped-request',
+ toolUseId: 'tool-grouped',
+ toolName: 'AskUserQuestion',
+ kind: 'question',
+ input: {
+ questions: [
+ { question: 'First?', options: [{ label: 'Yes' }] },
+ { question: 'Second?', options: [{ label: 'No' }] }
+ ]
+ },
+ suggestions: [],
+ questionIds: ['First?', 'Second?'],
+ settle: vi.fn()
}
- )
- const tryAppendLifecycleBatch = vi.fn(
- (
- _settlementId: string,
- mutations: Parameters<
- NonNullable
- >[1]
- ) => {
- expect(mutations[1]).toMatchObject({
- kind: 'item',
- body: { resolution: { state: 'cancelled' } }
- })
- return { accepted: false as const, reason: 'backpressure' as const }
- }
- )
- const prompts = new ClaudeJournalPrompts({
- sink: {
- appendItem: () => {},
- appendTombstone,
- tryAppendTombstone,
- tryAppendLifecycleBatch,
- publish: () => {}
- },
- questionItems: (input) => {
- const item = claudeQuestionItems(input)[0]
- return item
- ? [
- {
- ...item,
- identity: { provider: 'orca', clientMessageId: 'group:first' }
- },
- {
- ...item,
- identity: { provider: 'orca', clientMessageId: 'group:second' }
- }
- ]
- : []
- },
- turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
- })
- const prompt: ClaudePendingPrompt = {
- requestId: 'grouped-request',
- promptKey: 'grouped-request',
- toolUseId: 'tool-grouped',
- toolName: 'AskUserQuestion',
- kind: 'question',
- input: {
- questions: [
- { question: 'First?', options: [{ label: 'Yes' }] },
- { question: 'Second?', options: [{ label: 'No' }] }
- ]
- },
- suggestions: [],
- questionIds: ['First?', 'Second?'],
- settle: vi.fn()
- }
- prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
+ prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
+ await prompts.whenWritten(prompt.promptKey)
- expect(prompts.cancel(prompt.promptKey)).toEqual({
- accepted: false,
- reason: 'backpressure'
- })
- expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
- expect(tryAppendTombstone).not.toHaveBeenCalled()
- expect(tombstones).toEqual([])
- })
+ expect(tryAppendItem.mock.calls.map(([identity]) => agentJournalItemKey(identity))).toEqual([
+ 'orca:group%3Afirst',
+ 'orca:group%3Asecond'
+ ])
+ expect([...admittedRows.keys()]).toEqual(
+ ['orca:group%3Afirst', 'orca:group%3Asecond'].filter((_, index) => index !== refusedAt)
+ )
+ expect(bindPromptItemId.mock.calls).toEqual([
+ ['orca:group%3Afirst', prompt.promptKey],
+ ['orca:group%3Asecond', prompt.promptKey]
+ ])
+ expect(written).toHaveBeenCalledTimes(refusedAt === null ? 1 : 0)
+ expect([...prompts.openCards()]).toEqual(
+ refusedAt === null ? [{ promptKey: prompt.promptKey, asker: 'agent-grouped' }] : []
+ )
+
+ expect(prompts.cancel(prompt.promptKey)).toEqual({
+ accepted: false,
+ reason: 'backpressure'
+ })
+ expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
+ expect(tryAppendTombstone).not.toHaveBeenCalled()
+ expect(tombstones).toEqual([])
+ }
+ )
it('keeps every backpressured prompt cancellation retry in its owned entry', () => {
let backpressured = true
diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts
index bd88413edba..af6719938be 100644
--- a/src/main/claude/claude-structured-real-cli.test.ts
+++ b/src/main/claude/claude-structured-real-cli.test.ts
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join, relative } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
+import * as claudeConnection from './claude-stream-json-connection'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
@@ -11,7 +13,8 @@ import {
realClaudeAuthStatus,
realClaudeAvailable,
realClaudeCliGate,
- realClaudeCommand
+ realClaudeCommand,
+ realClaudeLaunchHome
} from './claude-real-cli-availability-test-support'
import {
ClaudeStructuredSessionAdapter,
@@ -27,13 +30,15 @@ function realAdapter(
claudeConfigDir: string,
events: ClaudeStructuredSessionEvent[] = [],
cwd = process.cwd(),
- onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
+ onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'],
+ env = realClaudeLaunchHome().env
): ClaudeStructuredSessionAdapter {
const adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: command,
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
cwd,
+ env,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
@@ -81,6 +86,53 @@ async function waitForResolvedTranscript(
}
}
+describe('real CLI fixture authentication isolation', () => {
+ it.each(['authenticated', 'signed-out'] as const)(
+ 'keeps the %s launch environment independent of inherited credentials',
+ async (mode) => {
+ const authKeys = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'] as const
+ for (const key of authKeys) {
+ vi.stubEnv(key, 'unusable-fixture-auth-value')
+ }
+ let childEnv: Record | undefined
+ const open = vi
+ .spyOn(claudeConnection, 'openClaudeStreamJsonConnection')
+ .mockImplementation(async (launch) => {
+ childEnv = buildClaudeChildProcessEnv(launch.env)
+ throw new Error('fixture stopped before spawning a CLI')
+ })
+ const providerSessionId = randomUUID()
+ const adapter = realAdapter(
+ providerSessionId,
+ join(tmpdir(), 'orca-synthetic-signed-out'),
+ [],
+ process.cwd(),
+ undefined,
+ mode === 'signed-out' ? {} : undefined
+ )
+ try {
+ await expect(
+ adapter.acquire({
+ identity: identity(providerSessionId),
+ fence: 1,
+ spawnToken: 'fixture'
+ })
+ ).rejects.toThrow('fixture stopped before spawning a CLI')
+ expect(open).toHaveBeenCalledOnce()
+ for (const key of authKeys) {
+ expect(childEnv?.[key]).toBe(
+ mode === 'authenticated' ? 'unusable-fixture-auth-value' : undefined
+ )
+ }
+ } finally {
+ await adapter.closeAll()
+ open.mockRestore()
+ vi.unstubAllEnvs()
+ }
+ }
+ )
+})
+
describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
it.skipIf(!realClaudeAuthenticated)(
'proves a pre-minted session before the first user message',
@@ -442,7 +494,14 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-'))
const providerSessionId = randomUUID()
const events: ClaudeStructuredSessionEvent[] = []
- const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
+ const adapter = realAdapter(
+ providerSessionId,
+ claudeConfigDir,
+ events,
+ process.cwd(),
+ undefined,
+ {}
+ )
try {
await adapter.acquire({
diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts
index 727732dd64c..7440b1a9ad8 100644
--- a/src/main/cli/orca-cli-child-path.test.ts
+++ b/src/main/cli/orca-cli-child-path.test.ts
@@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'linux'
})
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
+ expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR)
expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
userDataPath: USER_DATA
})
@@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => {
it('leaves packaged Linux PATH untouched when no shim could be written', () => {
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
- const env: Record = { PATH: '/usr/bin' }
+ const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' }
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
platform: 'linux'
})
expect(env.PATH).toBe('/usr/bin')
+ expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
})
it('leads packaged macOS PATH with the bundled CLI dir', () => {
@@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'darwin'
})
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
+ expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin'))
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
- const env: Record = { Path: 'C:\\Windows\\System32' }
+ const env: Record = {
+ Path: 'C:\\Windows\\System32',
+ ORCA_CLI_BIN_DIR: '/parent-host/cli',
+ ORCA_WSL_CLI_DIR: '/guest/orca/bin'
+ }
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
@@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => {
})
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
expect(env.PATH).toBeUndefined()
+ expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
+ expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin')
})
it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
@@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => {
platform
})
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
+ expect(env.ORCA_CLI_BIN_DIR).toBe(
+ platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin')
+ )
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts
index 2a8136c367f..a15943827cc 100644
--- a/src/main/cli/orca-cli-child-path.ts
+++ b/src/main/cli/orca-cli-child-path.ts
@@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath(
opts: OrcaCliChildPathOptions
): string | null {
const platform = opts.platform ?? process.platform
+ delete env.ORCA_CLI_BIN_DIR
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
// drives a foreign platform through the seam.
const pathDelimiter = platform === 'win32' ? ';' : delimiter
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
+ if (platform !== 'win32') {
+ env.ORCA_CLI_BIN_DIR = devCliBin
+ }
const inheritedPath = readInheritedPath(env, platform)
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
env[resolvePathEnvKey(env, platform)] = inheritedPath
@@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath(
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
if (shimDir) {
+ env.ORCA_CLI_BIN_DIR = shimDir
const inheritedEntries = readInheritedPath(env, platform)
.split(pathDelimiter)
.filter((entry) => entry.length > 0 && entry !== shimDir)
@@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath(
} else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
const bundledCliBin = join(opts.resourcesPath, 'bin')
+ if (platform === 'darwin') {
+ env.ORCA_CLI_BIN_DIR = bundledCliBin
+ }
const inheritedPath = readInheritedPath(env, platform)
env[resolvePathEnvKey(env, platform)] = inheritedPath
? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts
index 085777fdf8c..fc8f7b815a7 100644
--- a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts
+++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts
@@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => {
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
})
+ it('keeps an Orca-added MCP server while settings and trust still refresh', () => {
+ const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json')
+ const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] })
+ writeFileSync(baselinePath, baseline)
+ writeFileSync(
+ join(systemCodexHome, 'config.toml'),
+ ['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join(
+ '\n'
+ )
+ )
+ writeFileSync(
+ join(sharedRuntimeHome, 'config.toml'),
+ [
+ 'model = "stale"',
+ '',
+ '[projects."/trusted-in-orca"]',
+ 'trust_level = "trusted"',
+ '',
+ '[projects."/revoked"]',
+ 'trust_level = "trusted"',
+ '',
+ '[hooks.state."orca:stop:0:0"]',
+ 'enabled = true',
+ '',
+ '[mcp_servers.demo-mcp]',
+ 'command = "demo"',
+ ''
+ ].join('\n')
+ )
+
+ syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
+
+ const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
+ expect(sharedConfig).toContain('model = "canonical"')
+ expect(sharedConfig).not.toContain('model = "stale"')
+ expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"')
+ expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"')
+ expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"')
+ expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
+ expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"')
+ expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline)
+ })
+
it('does not delete config when the canonical source is transiently missing', () => {
const staleConfig = 'model_provider = "stale-provider"\n'
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts
index 2c8a86fd28b..44172582e21 100644
--- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts
+++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts
@@ -21,6 +21,20 @@ beforeEach(() => {
afterEach(() => rmSync(root, { recursive: true, force: true }))
+const BASELINE_FILE = '.orca-config-settings-baseline.json'
+
+type StoredBaselineFixture = {
+ version: 1 | 3
+ settings: Record
+ mcpServers?: string[]
+}
+
+function writeBaseline(baseline: StoredBaselineFixture): string {
+ const serialized = JSON.stringify(baseline)
+ writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
+ return serialized
+}
+
describe('canonical MCP ownership during config mirroring', () => {
it('does not duplicate a server defined inline in the canonical MCP table', () => {
writeFileSync(
@@ -130,6 +144,60 @@ describe('MCP ownership migration', () => {
)
})
+ it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
+ writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
+ writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
+ writeBaseline({ version: 1, settings: {} })
+
+ syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
+
+ expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
+ '[mcp_servers.'
+ )
+ })
+
+ it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
+ writeFileSync(
+ join(runtimeHomePath, 'config.toml'),
+ [
+ '[mcp_servers.demo-mcp]',
+ 'command = "orca-only"',
+ '[mcp_servers.removed]',
+ 'command = "mirrored"',
+ '[mcp_servers.kept]',
+ 'command = "stale"',
+ ''
+ ].join('\n')
+ )
+ writeFileSync(
+ join(systemHomePath, 'config.toml'),
+ 'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
+ )
+ const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
+
+ syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
+
+ const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
+ expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
+ expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
+ expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
+ expect(runtimeConfig).not.toContain('"stale"')
+ expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
+ })
+
+ it('leaves the retained shared home untouched when its baseline cannot be read', () => {
+ const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
+ writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
+ writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
+ mkdirSync(join(runtimeHomePath, BASELINE_FILE))
+
+ expect(() =>
+ syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
+ ).toThrow()
+
+ expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
+ })
+
it('tracks commented CRLF names so their later removal remains authoritative', () => {
writeFileSync(
join(runtimeHomePath, 'config.toml'),
diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts
index a97a8b9644c..74f8b2f0b53 100644
--- a/src/main/codex/codex-config-mirror.ts
+++ b/src/main/codex/codex-config-mirror.ts
@@ -16,7 +16,7 @@ import {
type CodexSettingsPromotionHomes,
type CodexSettingsPromotionPlan
} from './config-settings-promotion'
-import { readCodexSettingsBaseline } from './config-settings-baseline'
+import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline'
import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall'
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard'
@@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? ''
if (rawSystemConfig.trim() !== '') {
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
- // The retired home has no ownership baseline; its entire MCP root stays canonical.
mirroredRuntimeConfig =
runtimeConfigBeforeMirror !== null
? mergeSystemCodexConfigIntoRuntime(
runtimeConfigBeforeMirror,
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
sourceConfigDir,
- new Set(),
- true
+ ...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
)
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
}
@@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
}
+/**
+ * Why: MCP servers added from an Orca terminal exist only in the retired home,
+ * so its last mirror's baseline decides which ones ~/.codex owns. With no
+ * baseline the whole root stays canonical, as before; an unreadable one throws
+ * rather than guess. Read-only: this one-way refresh never advances it.
+ */
+function readLegacySharedHomeMcpOwnership(
+ runtimeHomePath: string
+): [mirroredMcpServerNames: ReadonlySet, mirroredMcpServerRoot: boolean] {
+ const observation = observeCodexSettingsBaseline(runtimeHomePath)
+ if (observation.kind === 'indeterminate') {
+ throw new Error('Codex settings baseline could not be read')
+ }
+ return observation.kind === 'present'
+ ? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot]
+ : [new Set(), true]
+}
+
type CodexConfigMirrorResult =
| { status: 'skipped-missing-source' }
| { status: 'refused-indeterminate'; error: unknown }
diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts
index 4dcd49e9645..517f9effb90 100644
--- a/src/main/codex/codex-structured-child-environment.test.ts
+++ b/src/main/codex/codex-structured-child-environment.test.ts
@@ -10,6 +10,7 @@ import {
} from '../runtime/structured-worker-identity'
const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/
+const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
@@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => {
cwd: '/worktree',
codexHome: '/pinned/home',
resumeThreadId: null,
- env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
+ env: {
+ EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
+ CODEX_HOME: '/shell/home',
+ ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
+ }
},
'spawn-token',
'session-not-a-worker'
@@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
+ ...(process.platform !== 'win32'
+ ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
+ : {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
@@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: sessionId,
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
+ ...(process.platform !== 'win32'
+ ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
+ : {}),
ORCA_USER_DATA_PATH: expect.any(String),
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
@@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw`
result: {
sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null,
cliCommand: process.env.ORCA_CLI_COMMAND ?? null,
+ cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null,
path: process.env.PATH ?? process.env.Path ?? null
}
})
@@ -135,6 +147,7 @@ describe('the spawned Codex child', () => {
await expect(connection.request('test/env')).resolves.toEqual({
sessionId,
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER),
+ cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR),
path: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
} finally {
diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts
index 70b2ecd7147..9276d670b4c 100644
--- a/src/main/codex/codex-structured-session-adapter.test.ts
+++ b/src/main/codex/codex-structured-session-adapter.test.ts
@@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
ORCA_AGENT_SESSION_ID: 'session-1',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/),
+ ...(process.platform !== 'win32'
+ ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) }
+ : {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/)
diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts
index 6ae5c7f4c63..bf2e2545102 100644
--- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts
+++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts
@@ -1,4 +1,5 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
+import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path'
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
@@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() {
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
+${ORCA_CLI_POSIX_PATH_RESTORE}
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
diff --git a/src/main/daemon/daemon-idle-inventory-budget.test.ts b/src/main/daemon/daemon-idle-inventory-budget.test.ts
new file mode 100644
index 00000000000..e83b6a122ce
--- /dev/null
+++ b/src/main/daemon/daemon-idle-inventory-budget.test.ts
@@ -0,0 +1,323 @@
+import './mock-descendant-sweep'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type * as CryptoModule from 'node:crypto'
+import { DaemonServer } from './daemon-server'
+import { DaemonClient } from './client'
+import { DaemonClientConnections } from './daemon-client-connections'
+import { TerminalHost } from './terminal-host'
+import { Session } from './session'
+import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
+import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
+import { getDaemonSocketPath } from './daemon-spawner'
+import type { SubprocessHandle } from './session-subprocess-handle'
+
+type MockSubprocess = SubprocessHandle & { exit: () => void }
+const seed = vi.hoisted(() => ({ uuid: 0 }))
+vi.mock('node:crypto', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ randomUUID: () => `00000000-0000-4000-8000-${String(++seed.uuid).padStart(12, '0')}`
+ }
+})
+const clients: DaemonClient[] = []
+const processes: MockSubprocess[] = []
+let dir = ''
+let server: DaemonServer | undefined
+
+async function waitFor(predicate: () => boolean): Promise {
+ const deadline = performance.now() + 2_000
+ while (!predicate()) {
+ if (performance.now() >= deadline) {
+ throw new Error('Daemon event did not settle')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+}
+
+function spawn(): MockSubprocess {
+ let exited: ((code: number) => void) | undefined
+ const proc: MockSubprocess = {
+ pid: 90_001,
+ getForegroundProcess: () => null,
+ write: vi.fn(),
+ resize: vi.fn(),
+ kill: () => exited?.(0),
+ forceKill: () => exited?.(137),
+ terminateOwnedTree: () => 'unavailable',
+ signal: vi.fn(),
+ onData: vi.fn(),
+ onExit: (callback) => {
+ exited = callback
+ },
+ dispose: vi.fn(),
+ exit: () => exited?.(0)
+ }
+ processes.push(proc)
+ return proc
+}
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'orca-idle-inventory-'))
+ clients.length = 0
+ processes.length = 0
+ seed.uuid = 0
+})
+
+afterEach(async () => {
+ for (const process of processes) {
+ process.exit()
+ }
+ for (const client of clients) {
+ client.disconnect()
+ }
+ await server?.shutdown()
+ vi.restoreAllMocks()
+ rmSync(dir, { recursive: true, force: true })
+})
+
+it('keeps live sessions and exact inventories while visitors repeatedly disconnect', async () => {
+ const socketPath = getDaemonSocketPath(dir)
+ const tokenPath = join(dir, 'daemon.token')
+ const idle = vi.fn()
+ const getClient = vi.spyOn(DaemonClientConnections.prototype, 'get')
+ const create = vi.spyOn(TerminalHost.prototype, 'createOrAttach')
+ server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess: spawn, onIdleShutdown: idle })
+ await server.start()
+ const owner = new DaemonClient({ socketPath, tokenPath })
+ clients.push(owner)
+ await owner.ensureConnected()
+ for (let index = 0; index < 16; index++) {
+ await owner.request('createOrAttach', {
+ sessionId: `session-${index}`,
+ cols: 80,
+ rows: 24,
+ ...(index < 8
+ ? {
+ agentSessionEnsure: {
+ claim: {
+ digestVersion: 1,
+ keyId: `key${index}`,
+ identityDigest: 'a'.repeat(43),
+ worktreeScopeDigest: 'b'.repeat(43),
+ agent: 'codex'
+ },
+ surface: {
+ worktreeId: `folder${index}`,
+ tabId: '11111111-1111-4111-8111-111111111111',
+ leafId: '22222222-2222-4222-8222-222222222222',
+ terminalHandle: `term_idle${index}`
+ }
+ }
+ }
+ : {})
+ })
+ }
+ const connections = getClient.mock.contexts[0]
+ if (!(connections instanceof DaemonClientConnections)) {
+ throw new Error('Actual server connections were not used')
+ }
+ const before = await owner.request('listSessions', undefined)
+ const expected = structuredClone(before)
+ if (
+ typeof before !== 'object' ||
+ before === null ||
+ !('sessions' in before) ||
+ !Array.isArray(before.sessions)
+ ) {
+ throw new Error('Initial inventory was malformed')
+ }
+ const first = before.sessions[0]
+ if (
+ typeof first !== 'object' ||
+ first === null ||
+ !('agentSessionOwners' in first) ||
+ !Array.isArray(first.agentSessionOwners) ||
+ !first.agentSessionOwners.every(isAgentSessionOwnerBinding)
+ ) {
+ throw new Error('Initial owners were malformed')
+ }
+ const priorOwner = first.agentSessionOwners[0]
+ priorOwner.claim.keyId = 'mutated-client-copy'
+ priorOwner.surface.terminalHandle = 'term_mutated'
+ const inventory = vi.spyOn(TerminalHost.prototype, 'listSessions')
+ const owners = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty')
+ const sizes = vi.spyOn(Session.prototype, 'getAppliedSize')
+ const cwds = vi.spyOn(Session.prototype, 'getCwd')
+ const originalAlive = Object.getOwnPropertyDescriptor(Session.prototype, 'isAlive')?.get
+ if (!originalAlive) {
+ throw new Error('Session alive getter is missing')
+ }
+ const alive = vi.spyOn(Session.prototype, 'isAlive', 'get')
+ for (let iteration = 0; iteration < 10; iteration++) {
+ const visitor = new DaemonClient({ socketPath, tokenPath })
+ clients.push(visitor)
+ await visitor.ensureConnected()
+ expect(await visitor.request('ping', undefined)).toEqual({ pong: true })
+ visitor.disconnect()
+ await waitFor(() => connections.size === 1 && connections.transportCount === 2)
+ expect(idle).not.toHaveBeenCalled()
+ }
+ const calls = inventory.mock.calls.length
+ const sizeCalls = sizes.mock.calls.length
+ const cwdCalls = cwds.mock.calls.length
+ const readSessionIds = (): string[] =>
+ alive.mock.contexts.map((session) => {
+ if (!(session instanceof Session)) {
+ throw new Error('Actual Session was not read')
+ }
+ return session.sessionId
+ })
+ const aliveReads = readSessionIds()
+ const ownerCalls = owners.mock.calls.length
+ const copiedOwners = owners.mock.results.reduce((total, result) => {
+ const value: unknown = result.value
+ if (!Array.isArray(value)) {
+ throw new Error('Owner result was not an array')
+ }
+ return total + value.length
+ }, 0)
+ const after = await owner.request('listSessions', undefined)
+ expect(after).toEqual(expected)
+ expect(JSON.stringify(after)).toBe(JSON.stringify(expected))
+ expect(after).not.toBe(before)
+ if (
+ typeof after !== 'object' ||
+ after === null ||
+ !('sessions' in after) ||
+ !Array.isArray(after.sessions)
+ ) {
+ throw new Error('Fresh inventory was malformed')
+ }
+ const fresh = after.sessions[0]
+ if (
+ typeof fresh !== 'object' ||
+ fresh === null ||
+ !('agentSessionOwners' in fresh) ||
+ !Array.isArray(fresh.agentSessionOwners) ||
+ !fresh.agentSessionOwners.every(isAgentSessionOwnerBinding)
+ ) {
+ throw new Error('Fresh owners were malformed')
+ }
+ expect(fresh.agentSessionOwners).not.toBe(first.agentSessionOwners)
+ expect(fresh.agentSessionOwners[0]).not.toBe(priorOwner)
+ expect(fresh.agentSessionOwners[0].claim).not.toBe(priorOwner.claim)
+ expect(fresh.agentSessionOwners[0].surface).not.toBe(priorOwner.surface)
+ expect(idle).not.toHaveBeenCalled()
+ const host = create.mock.contexts[0]
+ if (!(host instanceof TerminalHost)) {
+ throw new Error('Actual host was not used')
+ }
+ const hasLive = (): boolean => {
+ const candidate: unknown = host
+ if (
+ typeof candidate === 'object' &&
+ candidate !== null &&
+ 'hasLiveSessions' in candidate &&
+ typeof candidate.hasLiveSessions === 'function'
+ ) {
+ const result: unknown = candidate.hasLiveSessions()
+ if (typeof result !== 'boolean') {
+ throw new Error('Live result was not boolean')
+ }
+ return result
+ }
+ return host.listSessions().length > 0
+ }
+ const readAlive = (session: Session): boolean => {
+ const result: unknown = originalAlive.call(session)
+ if (typeof result !== 'boolean') {
+ throw new Error('Original alive getter was malformed')
+ }
+ return result
+ }
+ const controlReads: string[][] = []
+ const failure = new Error('later-session-alive-failure')
+ alive.mockImplementation(function (this: Session) {
+ if (this.sessionId === 'session-1') {
+ throw failure
+ }
+ return readAlive(this)
+ })
+ let caught: unknown
+ try {
+ hasLive()
+ } catch (error) {
+ caught = error
+ }
+ expect(caught).toBe(failure)
+ const sessionIds = Array.from({ length: 16 }, (_value, index) => `session-${index}`)
+ const deadIds = new Set(sessionIds)
+ alive.mockClear().mockImplementation(function (this: Session) {
+ return !deadIds.has(this.sessionId) && readAlive(this)
+ })
+ expect(hasLive()).toBe(false)
+ controlReads.push(readSessionIds())
+ deadIds.delete('session-3')
+ alive.mockClear()
+ expect(hasLive()).toBe(true)
+ controlReads.push(readSessionIds())
+ deadIds.add('session-3')
+ alive.mockClear()
+ expect(hasLive()).toBe(false)
+ controlReads.push(readSessionIds())
+ let entered = false
+ let nested: boolean | undefined
+ alive.mockClear().mockImplementation(function (this: Session) {
+ if (!entered) {
+ entered = true
+ processes[1].exit()
+ nested = hasLive()
+ }
+ return readAlive(this)
+ })
+ expect(hasLive()).toBe(true)
+ expect(nested).toBe(true)
+ controlReads.push(readSessionIds())
+ alive.mockRestore()
+ if (
+ typeof expected !== 'object' ||
+ expected === null ||
+ !('sessions' in expected) ||
+ !Array.isArray(expected.sessions)
+ ) {
+ throw new Error('Inventory was malformed')
+ }
+ const remaining = {
+ ...expected,
+ sessions: expected.sessions.filter(
+ (row: unknown) =>
+ typeof row === 'object' &&
+ row !== null &&
+ 'sessionId' in row &&
+ row.sessionId !== 'session-1'
+ )
+ }
+ const afterExit = await owner.request('listSessions', undefined)
+ expect(afterExit).toEqual(remaining)
+ expect(JSON.stringify(afterExit)).toBe(JSON.stringify(remaining))
+ expect(aliveReads).toEqual(Array.from({ length: 30 }, () => sessionIds).flat())
+ const remainingIds = sessionIds.filter((id) => id !== 'session-1')
+ expect(controlReads).toEqual([
+ sessionIds,
+ sessionIds,
+ sessionIds,
+ [
+ sessionIds[0],
+ ...sessionIds,
+ sessionIds[1],
+ ...remainingIds,
+ ...remainingIds,
+ ...sessionIds.slice(2)
+ ]
+ ])
+ expect(calls).toBe(0)
+ expect(ownerCalls).toBe(0)
+ expect(copiedOwners).toBe(0)
+ expect(sizeCalls).toBe(0)
+ expect(cwdCalls).toBe(0)
+})
diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts
index ab22af24100..a917891f109 100644
--- a/src/main/daemon/daemon-server.ts
+++ b/src/main/daemon/daemon-server.ts
@@ -233,7 +233,7 @@ export class DaemonServer {
}
private isIdle(): boolean {
- if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) {
+ if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) {
return false
}
if (this.endpoint.lost) {
diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts
index 490b40a1c9d..8aec878b790 100644
--- a/src/main/daemon/serialize-grid-cell-descriptors.test.ts
+++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { Terminal } from '@xterm/headless'
-import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
+import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors'
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
@@ -78,48 +78,6 @@ function allocatingBufferRows(
}
describe('serialize oracle cell reuse', () => {
- it.each([false, true])(
- 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
- (conpty) => {
- const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
- try {
- const writes = [
- 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
- '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
- '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
- 'scroll1\r\nscroll2\r\nscroll3\r\n',
- '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
- '\x1b[?1049l\x1b[2J\x1b[Hclear'
- ]
- for (const data of writes) {
- writeTerminal(terminal, data)
- for (const buffer of [
- terminal.buffer.normal,
- terminal.buffer.alternate,
- terminal.buffer.active
- ]) {
- expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
- allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
- )
- }
- terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
- expect(
- bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
- ).toEqual(
- allocatingBufferRows(
- terminal.buffer.active,
- 0,
- terminal.buffer.active.length,
- terminal.cols
- )
- )
- }
- } finally {
- terminal.dispose()
- }
- }
- )
-
it('preserves the order of every text flag combination while reloading a plain cell', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
try {
@@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => {
const scratch = terminal.buffer.active.getNullCell()
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
- expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
- allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
- )
} finally {
terminal.dispose()
}
})
- it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
- const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
- try {
- writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
- const buffer = terminal.buffer.active
- const scratch = buffer.getNullCell()
- const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
- const getLine = buffer.getLine.bind(buffer)
- const loaded: unknown[] = []
- vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
- const line = getLine(y)
- if (line) {
- const getCell = line.getCell.bind(line)
- vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
- loaded.push(cell)
- return getCell(x, cell)
- })
- }
- return line
- })
- const expected = allocatingBufferRows(buffer, 0, 2, 4)
- loaded.length = 0
- const actual = bufferRows(buffer, 0, 2, 4)
- expect(actual).toEqual(expected)
- expect(allocate).toHaveBeenCalledTimes(1)
- expect(loaded).toHaveLength(8)
- expect(loaded.every((cell) => cell === scratch)).toBe(true)
- writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
- bufferRows(buffer, 0, 2, 4)
- expect(actual).toEqual(expected)
- } finally {
- terminal.dispose()
- vi.restoreAllMocks()
- }
- })
-
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
try {
@@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => {
}
})
})
+
+function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) {
+ for (let y = 0; y < Math.max(expected.length, actual.length); y++) {
+ const expectedRow = expected[y]
+ const actualRow = actual[y]
+ if (
+ !expectedRow ||
+ !actualRow ||
+ expectedRow.length !== actualRow.length ||
+ !expectedRow.every(
+ (cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯'))
+ )
+ ) {
+ return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') }
+ }
+ }
+ return null
+}
+
+function expectComparisonParity(
+ expected: Buffer,
+ actual: Buffer,
+ cols: number,
+ expectedStart = 0,
+ expectedEnd = expected.length,
+ actualStart = 0,
+ actualEnd = actual.length
+): ReturnType {
+ const frozen = allocatingRowDiff(
+ 'parity',
+ allocatingBufferRows(expected, expectedStart, expectedEnd, cols),
+ allocatingBufferRows(actual, actualStart, actualEnd, cols)
+ )
+ expect(
+ compareBufferRows(
+ 'parity',
+ expected,
+ expectedStart,
+ expectedEnd,
+ actual,
+ actualStart,
+ actualEnd,
+ cols
+ )
+ ).toEqual(frozen)
+ return frozen
+}
+
+describe('serialize oracle streaming comparison', () => {
+ it('reuses one cell per buffer and stops before rows after the first difference', () => {
+ const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
+ const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
+ try {
+ writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast')
+ writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast')
+ for (const buffer of [source.buffer.active, replay.buffer.active]) {
+ const scratch = buffer.getNullCell()
+ vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
+ const getLine = buffer.getLine.bind(buffer)
+ vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
+ const line = getLine(y)
+ if (line) {
+ const getCell = line.getCell.bind(line)
+ vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
+ expect(cell).toBe(scratch)
+ return getCell(x, cell)
+ })
+ }
+ return line
+ })
+ }
+ const diff = compareBufferRows(
+ 'visible-grid',
+ source.buffer.active,
+ 0,
+ 4,
+ replay.buffer.active,
+ 0,
+ 4,
+ 8
+ )
+ expect(diff?.row).toBe(0)
+ for (const buffer of [source.buffer.active, replay.buffer.active]) {
+ expect(buffer.getNullCell).toHaveBeenCalledTimes(1)
+ expect(buffer.getLine).toHaveBeenCalledTimes(2)
+ expect(buffer.getLine).toHaveBeenCalledWith(0)
+ expect(buffer.getLine).toHaveBeenCalledWith(3)
+ }
+ writeTerminal(source, '\x1b[2J\x1b[Hchanged')
+ expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000')
+ } finally {
+ source.dispose()
+ replay.dispose()
+ vi.restoreAllMocks()
+ }
+ })
+
+ it.each([false, true])(
+ 'preserves first-row diagnostics through buffer changes (ConPTY=%s)',
+ (conpty) => {
+ const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
+ const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
+ try {
+ for (const [index, data] of [
+ 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
+ '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
+ '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
+ 'scroll1\r\nscroll2\r\nscroll3\r\n',
+ '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
+ '\x1b[?1049l\x1b[2J\x1b[Hclear'
+ ].entries()) {
+ writeTerminal(source, data)
+ writeTerminal(replay, data)
+ for (const [expected, actual] of [
+ [source.buffer.active, replay.buffer.active],
+ [source.buffer.normal, replay.buffer.normal],
+ [source.buffer.alternate, replay.buffer.alternate]
+ ]) {
+ expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull()
+ expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1)
+ }
+ const corruption = `\x1b[H\x1b[0mFAULT${index}`
+ writeTerminal(replay, corruption)
+ expect(
+ expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
+ ).not.toBeNull()
+ writeTerminal(source, corruption)
+ source.resize(source.cols === 14 ? 9 : 14, 4)
+ replay.resize(source.cols, 4)
+ expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
+ }
+ } finally {
+ source.dispose()
+ replay.dispose()
+ }
+ }
+ )
+
+ it('detects every text-flag loss and compares all combinations against the allocating oracle', () => {
+ const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
+ const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
+ try {
+ const sgr = [1, 2, 3, 4, 7, 8, 9]
+ const writeFlags = (terminal: Terminal, mask: number): void => {
+ const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
+ writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
+ }
+ for (let mask = 0; mask < 128; mask++) {
+ writeFlags(source, mask)
+ writeFlags(replay, mask)
+ expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull()
+ for (let bit = 0; bit < sgr.length; bit++) {
+ if ((mask & (1 << bit)) !== 0) {
+ writeFlags(replay, mask & ~(1 << bit))
+ expect(
+ expectComparisonParity(source.buffer.active, replay.buffer.active, 4)
+ ).not.toBeNull()
+ }
+ }
+ }
+ } finally {
+ source.dispose()
+ replay.dispose()
+ }
+ })
+
+ it.each([
+ ['abc界', 'abc ', 4, false],
+ ['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false],
+ ['abc ', 'abc界', 4, true],
+ ['é', 'è', 8, true],
+ ['\x1b[32mA', '\x1b[38;5;2mA', 8, false],
+ ['\x1b[42m ', '\x1b[48;5;2m ', 8, false],
+ ['\x1b[7;31m ', '\x1b[7;32m ', 8, true],
+ ['\x1b[4m\x1b[2J', '\x1b[2J', 8, false],
+ ['\x1b[4m ', ' ', 8, true],
+ ['\x1b[4m \x1b[0mB', ' B', 8, true],
+ ['text\r\n', 'text', 8, false],
+ ['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true]
+ ] as const)(
+ 'preserves blank, clipped and color policy for %j / %j',
+ (expected, actual, cols, differs) => {
+ const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
+ const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
+ try {
+ writeTerminal(source, expected)
+ writeTerminal(replay, actual)
+ expect(
+ Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols))
+ ).toBe(differs)
+ expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5)
+ expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4)
+ } finally {
+ source.dispose()
+ replay.dispose()
+ }
+ }
+ )
+})
diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts
index 8b6c331f5fc..9adbbc6c694 100644
--- a/src/main/daemon/serialize-grid-cell-descriptors.ts
+++ b/src/main/daemon/serialize-grid-cell-descriptors.ts
@@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual:
type BufferLine = NonNullable>
type Buffer = Terminal['buffer']['active']
+type Cell = ReturnType
const COLOR_MODE_P16 = 16777216
const COLOR_MODE_P256 = 33554432
@@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean {
return expected === actual || (expected === CLIPPED && actual.startsWith('▯'))
}
-function rowsMatch(expected: string[], actual: string[]): boolean {
- return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!))
+function sameColor(
+ expectedMode: number,
+ expectedColor: number,
+ actualMode: number,
+ actualColor: number
+): boolean {
+ return (
+ expectedColor === actualColor &&
+ canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor)
+ )
}
-export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
- const rows: string[][] = []
- const reusableCell = buffer.getNullCell()
- for (let y = start; y < end; y++) {
- rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
+// Equal public fields imply identical descriptors; differing fields still use the frozen policy.
+function sameCellFields(expected: Cell, actual: Cell): boolean {
+ if (
+ expected.getWidth() !== actual.getWidth() ||
+ !sameColor(
+ expected.getBgColorMode(),
+ expected.getBgColor(),
+ actual.getBgColorMode(),
+ actual.getBgColor()
+ )
+ ) {
+ return false
}
- while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
- rows.pop()
+ const expectedChars = expected.getChars()
+ const actualChars = actual.getChars()
+ const expectedBlank = expectedChars === '' || expectedChars === ' '
+ const actualBlank = actualChars === '' || actualChars === ' '
+ if (expectedBlank || actualBlank) {
+ return (
+ expectedBlank &&
+ actualBlank &&
+ (expectedChars === ' ' && expected.isUnderline() !== 0) ===
+ (actualChars === ' ' && actual.isUnderline() !== 0) &&
+ (expectedChars === ' ' && expected.isStrikethrough() !== 0) ===
+ (actualChars === ' ' && actual.isStrikethrough() !== 0) &&
+ (expectedChars === ' ' && expected.isOverline() !== 0) ===
+ (actualChars === ' ' && actual.isOverline() !== 0) &&
+ (expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
+ (expected.isInverse() === 0 ||
+ sameColor(
+ expected.getFgColorMode(),
+ expected.getFgColor(),
+ actual.getFgColorMode(),
+ actual.getFgColor()
+ ))
+ )
}
- return rows
+ return (
+ expectedChars === actualChars &&
+ sameColor(
+ expected.getFgColorMode(),
+ expected.getFgColor(),
+ actual.getFgColorMode(),
+ actual.getFgColor()
+ ) &&
+ (expected.isBold() !== 0) === (actual.isBold() !== 0) &&
+ (expected.isDim() !== 0) === (actual.isDim() !== 0) &&
+ (expected.isItalic() !== 0) === (actual.isItalic() !== 0) &&
+ (expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) &&
+ (expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
+ (expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) &&
+ (expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0)
+ )
}
-export function compareRowSets(
+function trimmedEnd(
+ buffer: Buffer,
+ start: number,
+ end: number,
+ cols: number,
+ scratch: Cell
+): number {
+ while (end > start) {
+ const line = buffer.getLine(end - 1)
+ let blank = true
+ for (let x = 0; x < cols; x++) {
+ if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) {
+ blank = false
+ break
+ }
+ }
+ if (!blank) {
+ break
+ }
+ end--
+ }
+ return end
+}
+
+function lineCellsMatch(
+ expected: BufferLine | undefined,
+ actual: BufferLine | undefined,
+ cols: number,
+ expectedScratch: Cell,
+ actualScratch: Cell
+): boolean {
+ for (let x = 0; x < cols; x++) {
+ const expectedCell =
+ expected && x < expected.length ? expected.getCell(x, expectedScratch) : null
+ const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null
+ const clipped =
+ x === cols - 1 &&
+ ((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) ||
+ (actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1))
+ if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) {
+ continue
+ }
+ if (
+ !cellsMatch(
+ cellDescriptor(expected, x, cols, expectedScratch),
+ cellDescriptor(actual, x, cols, actualScratch)
+ )
+ ) {
+ return false
+ }
+ }
+ return true
+}
+
+/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */
+export function compareBufferRows(
stage: string,
- expected: string[][],
- actual: string[][]
+ expected: Buffer,
+ expectedStart: number,
+ expectedEnd: number,
+ actual: Buffer,
+ actualStart: number,
+ actualEnd: number,
+ cols: number
): GridDiff | null {
- const length = Math.max(expected.length, actual.length)
- for (let y = 0; y < length; y++) {
- const e = expected[y]
- const a = actual[y]
- if (!e || !a || !rowsMatch(e, a)) {
- return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') }
+ const expectedScratch = expected.getNullCell()
+ const actualScratch = actual.getNullCell()
+ const expectedLength =
+ trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart
+ const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart
+ for (let y = 0; y < Math.max(expectedLength, actualLength); y++) {
+ const expectedLine = expected.getLine(expectedStart + y)
+ const actualLine = actual.getLine(actualStart + y)
+ if (
+ y >= expectedLength ||
+ y >= actualLength ||
+ !lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch)
+ ) {
+ return {
+ stage,
+ row: y,
+ expected:
+ y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined,
+ actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined
+ }
}
}
return null
diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts
index d22bee6e511..26176c9ff5a 100644
--- a/src/main/daemon/serialize-grid-roundtrip.ts
+++ b/src/main/daemon/serialize-grid-roundtrip.ts
@@ -18,12 +18,7 @@ import {
variantTrailingBackgroundRows
} from './serialize-grid-variant-scope'
import type { GridDiff } from './serialize-grid-cell-descriptors'
-import {
- bufferRows,
- cellDescriptor,
- CLIPPED,
- compareRowSets
-} from './serialize-grid-cell-descriptors'
+import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors'
export type NamedSerializer = { name: string; create: () => SerializeAddon }
@@ -156,15 +151,25 @@ async function compareReplay(
(src.type === dst.type
? null
: { stage: 'active-buffer', expected: src.type, actual: dst.type }) ??
- compareRowSets(
+ compareBufferRows(
'visible-grid',
- bufferRows(src, src.baseY, src.baseY + rows, cols),
- bufferRows(dst, dst.baseY, dst.baseY + rows, cols)
+ src,
+ src.baseY,
+ src.baseY + rows,
+ dst,
+ dst.baseY,
+ dst.baseY + rows,
+ cols
) ??
- compareRowSets(
+ compareBufferRows(
'normal-buffer',
- bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols),
- bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols)
+ source.buffer.normal,
+ normalStart,
+ source.buffer.normal.length,
+ replay.buffer.normal,
+ 0,
+ replay.buffer.normal.length,
+ cols
) ??
(src.cursorX === dst.cursorX && src.cursorY === dst.cursorY
? null
diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts
index d39078692c6..9a28f0a243a 100644
--- a/src/main/daemon/serialize-grid-transcript-replay.test.ts
+++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts
@@ -253,7 +253,13 @@ describe('serialize round trip over captured PTY transcripts', () => {
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
])
}
- if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
+ // The pre-#22586 serializer has these same row-1 bold diffs on the untouched capture.
+ if (SEEDS === 2 && transcript.name === 'dsb-6-9-0-folder') {
+ expect(failureSignatures).toEqual([
+ 'jitter/false/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27',
+ 'jitter/true/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27'
+ ])
+ } else if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0)
diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts
index 0fbdce87029..059612f5a4b 100644
--- a/src/main/daemon/shell-ready-bash-wrapper.test.ts
+++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile'
+import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
+import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
+import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
import {
OVERLAY_ONLY_FEATURES,
STARTUP_COMMAND_FEATURES
@@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => {
vi.restoreAllMocks()
})
+ itWithBash.each([
+ ['daemon', getDaemonBashShellReadyRcfileContent],
+ ['local', getBashShellReadyRcfileContent]
+ ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => {
+ const cliBin = join(userDataPath, 'cli', 'bin')
+ const ambientBin = join(userDataPath, 'ambient-bin')
+ mkdirSync(cliBin, { recursive: true })
+ mkdirSync(ambientBin)
+ for (const bin of [cliBin, ambientBin]) {
+ const launcher = join(bin, 'orca-dev')
+ writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
+ chmodSync(launcher, 0o755)
+ }
+ const env: Record = {
+ HOME: userDataPath,
+ USERPROFILE: userDataPath,
+ PATH: `${ambientBin}:/usr/bin:/bin`,
+ ORCA_BACKGROUND_LAUNCH: '1'
+ }
+ const expectedLauncher = prependOrcaCliDirToChildPath(env, {
+ isPackaged: false,
+ userDataPath
+ })
+ writeFileSync(
+ join(userDataPath, '.bash_profile'),
+ 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n'
+ )
+ const rcfile = join(userDataPath, 'cli-path-rcfile')
+ writeFileSync(rcfile, content())
+ const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], {
+ env,
+ encoding: 'utf8',
+ timeout: 5000
+ })
+ expect(result.error).toBeUndefined()
+ expect(result.status).toBe(0)
+ expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher)
+ })
+
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts
index 86066b95b8c..29afe00618b 100644
--- a/src/main/daemon/terminal-cursor-line-context.test.ts
+++ b/src/main/daemon/terminal-cursor-line-context.test.ts
@@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void {
core.writeSync(data)
}
+type Cell = ReturnType
+
+function observeCellReads(terminal: Terminal) {
+ const allocated: Cell[] = []
+ const targets: (Cell | undefined)[] = []
+ const active = terminal.buffer.active
+ const source = {
+ rows: terminal.rows,
+ modes: terminal.modes,
+ buffer: {
+ active: {
+ baseY: active.baseY,
+ cursorX: active.cursorX,
+ cursorY: active.cursorY,
+ viewportY: active.viewportY,
+ getNullCell: () => {
+ const cell = active.getNullCell()
+ allocated.push(cell)
+ return cell
+ },
+ getLine: (row: number) => {
+ const line = active.getLine(row)
+ if (!line) {
+ return undefined
+ }
+ return {
+ isWrapped: line.isWrapped,
+ length: line.length,
+ translateToString: line.translateToString.bind(line),
+ getCell: (column: number, reusableCell?: Cell) => {
+ targets.push(reusableCell)
+ return line.getCell(column, reusableCell)
+ }
+ }
+ }
+ }
+ }
+ }
+ return { source, allocated, targets }
+}
+
describe('readTerminalCursorLineContext', () => {
+ it('reuses one cell across every scan without retaining it between reads', () => {
+ const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
+ try {
+ writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8')
+ const rig = observeCellReads(terminal)
+ const first = readTerminalCursorLineContext(rig.source, terminal.rows)
+ expect(rig.allocated).toHaveLength(1)
+ expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
+ expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
+ rig.targets.length = 0
+ expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first)
+ expect(rig.allocated).toHaveLength(2)
+ expect(rig.allocated[1]).not.toBe(rig.allocated[0])
+ expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true)
+ } finally {
+ terminal.dispose()
+ }
+ })
+
+ it('preserves full context for an adapter without reusable cells', () => {
+ const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
+ try {
+ writeSync(
+ terminal,
+ '\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8'
+ )
+ const rig = observeCellReads(terminal)
+ const source = {
+ ...rig.source,
+ buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } }
+ }
+ const context = readTerminalCursorLineContext(source, terminal.rows)
+ expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows))
+ expect(context?.typedRows).toEqual(['❯ typed'])
+ expect(context?.typedRowsBelow[0]).toBe('界e\u0301')
+ expect(context?.beforeCursor).toBe('❯ typed')
+ expect(context?.afterCursor).toBe('')
+ expect(context?.rawAfterCursor).toBe(' hint')
+ expect(context?.promptGlyphBoldRows).toEqual([true])
+ expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
+ expect(rig.allocated).toEqual([])
+ expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
+ expect(rig.targets.every((cell) => cell === undefined)).toBe(true)
+ } finally {
+ terminal.dispose()
+ }
+ })
+
it.each([
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts
index d5669618c7a..93b4dee6e58 100644
--- a/src/main/daemon/terminal-host.ts
+++ b/src/main/daemon/terminal-host.ts
@@ -1,20 +1,14 @@
import type { Session } from './session'
-import {
- SessionNotFoundError,
- type SessionInfo,
- type TakePendingOutputResult,
- type TerminalSnapshot
-} from './types'
+import { SessionNotFoundError } from './types'
+import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
import type { CreateOrAttachResult } from './terminal-host-create-contract'
import type { TerminalHostOptions } from './terminal-host-options'
import { disposeTerminalHostSessions } from './terminal-host-disposal'
import { getAliveTerminalHostSession } from './terminal-host-session-access'
import { TerminalSessionTeardown } from './terminal-session-teardown'
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
-import {
- createOrAttachClaimedAgentSession,
- type InternalCreateOrAttachOptions
-} from './terminal-host-agent-session-claim'
+import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim'
+import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations'
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
@@ -24,10 +18,8 @@ import { TerminalAttachCanceledError } from './daemon-errors'
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
import { randomUUID } from 'node:crypto'
import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations'
-import {
- inspectTerminalHostProcess,
- type TerminalHostProcessInspection
-} from './terminal-host-process-inspection'
+import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
+import type { TerminalHostProcessInspection } from './terminal-host-process-inspection'
import {
confirmTerminalHostForegroundProcess,
confirmTerminalHostShellForeground,
@@ -320,6 +312,15 @@ export class TerminalHost {
return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners)
}
+ hasLiveSessions(): boolean {
+ let hasLive = false
+ // Read every Session, even after a live one, to preserve the inventory's error order.
+ for (const session of this.sessions.values()) {
+ hasLive = session.isAlive || hasLive
+ }
+ return hasLive
+ }
+
dispose(): Promise {
this.creationFenced = true
if (this.disposePromise) {
diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts
index b94d2f59509..267fc7e20a5 100644
--- a/src/main/git/command-runner/exec-file-capture.ts
+++ b/src/main/git/command-runner/exec-file-capture.ts
@@ -2,6 +2,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write'
import { runProcess } from '../../../shared/child-process/run-process'
+import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver'
import type { WslProcessGroupTermination } from '../wsl-process-group-termination'
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
@@ -30,7 +31,10 @@ export async function execFileCaptureToTermination(
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
// through; recording it again here would double-count every capture.
const pending = runProcess({
- program: command,
+ program: resolveSelectedLocalCommand(command, {
+ env: options.env,
+ cwd: typeof options.cwd === 'string' ? options.cwd : undefined
+ }),
args,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
env: options.env,
diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts
index 2fb3e896b20..f7ebc3036b2 100644
--- a/src/main/git/command-runner/git-admission-storm-measurement.test.ts
+++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts
@@ -1,250 +1,183 @@
-/**
- * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
- * Timing distributions are reported for field comparison; CI assertions stay structural.
- */
-import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
+import { existsSync, watch } from 'node:fs'
+import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { runProcess } from '../../../shared/child-process/run-process'
import { gitExecFileAsync } from './git-exec-file'
import {
- GIT_ADMISSION_AGING_MS,
+ GENERAL_CAP,
+ GENERAL_HEADROOM,
GitAdmissionScheduler,
- MAX_GIT_CHILDREN,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests,
type GitAdmissionEvent
} from './git-subprocess-admission'
+import {
+ assertAdmissionLedger,
+ createStormRoot,
+ formatMeasurementTable,
+ measureStorm,
+ type InteractiveQueueSnapshot
+} from './git-admission-storm-test-fixture'
-type StormMeasurement = {
- mode: 'disabled' | 'enabled'
- maxConcurrentChildren: number
- eventLoopMaxDriftMs: number
- eventLoopP99DriftMs: number
- interactiveP50Ms: number
- interactiveP95Ms: number
- totalWallMs: number
- admissionEvents: GitAdmissionEvent[]
- interactiveQueueSnapshots: InteractiveQueueSnapshot[]
+function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const watcher = watch(stateDir)
+ const finish = (error?: Error): void => {
+ clearTimeout(deadline)
+ watcher.close()
+ signal.removeEventListener('abort', aborted)
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ }
+ const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`))
+ const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000)
+ const check = (): void => {
+ if (existsSync(path.join(stateDir, `${id}.live`))) {
+ finish()
+ }
+ }
+ watcher.on('change', check)
+ watcher.once('error', finish)
+ signal.addEventListener('abort', aborted, { once: true })
+ if (signal.aborted) {
+ aborted()
+ } else {
+ check()
+ }
+ })
}
-type InteractiveQueueSnapshot = {
- commandLabel: string
- backgroundWaiterIds: number[]
-}
-
-const tempRoots: string[] = []
-const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
-
-afterEach(async () => {
- if (originalAdmissionDisabled === undefined) {
- delete process.env.ORCA_GIT_ADMISSION_DISABLED
- } else {
- process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
- }
- _resetGitAdmissionForTests()
- await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
-})
-
-function percentile(values: readonly number[], percentileValue: number): number {
- if (values.length === 0) {
- return 0
- }
- const sorted = [...values].sort((a, b) => a - b)
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
-}
-
-async function liveChildCount(stateDir: string): Promise {
- return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
-}
-
-async function createStubGit(root: string): Promise {
+async function verifySpawnContention(): Promise {
+ delete process.env.ORCA_GIT_ADMISSION_DISABLED
+ const admissionEvents: GitAdmissionEvent[] = []
+ _resetGitAdmissionForTests(
+ new GitAdmissionScheduler({
+ now: () => 0,
+ onAdmissionEvent: (event) => admissionEvents.push(event)
+ })
+ )
+ const root = await createStormRoot('contention')
+ const stateDir = path.join(root, 'state')
+ const gateDir = path.join(root, 'gates')
const binDir = path.join(root, 'bin')
- await mkdir(binDir)
- const stubPath = path.join(binDir, 'git')
+ await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory)))
+ const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`)
+ const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`)
+ const queuedIds = ['interactive-queued-0', 'interactive-queued-1']
+ const ids = [...backgroundIds, ...headroomIds, ...queuedIds]
+ const gates = await runProcess({
+ program: 'mkfifo',
+ args: ids.map((id) => path.join(gateDir, id))
+ })
+ expect(gates.code, gates.stderr).toBe(0)
+ const stub = path.join(binDir, 'git')
await writeFile(
- stubPath,
+ stub,
`#!/bin/sh
set -eu
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
: > "$live"
trap 'rm -f "$live"' EXIT HUP INT TERM
-sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
+IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID"
printf 'stub:%s\\n' "$*"
`
)
- await chmod(stubPath, 0o755)
- return binDir
-}
-
-function setAdmissionMode(mode: StormMeasurement['mode']): void {
- if (mode === 'disabled') {
- process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
- } else {
- delete process.env.ORCA_GIT_ADMISSION_DISABLED
- }
-}
-
-async function measureStorm(mode: StormMeasurement['mode']): Promise {
- setAdmissionMode(mode)
- const admissionEvents: GitAdmissionEvent[] = []
- const admissionClockStartedAt = performance.now()
- _resetGitAdmissionForTests(
- new GitAdmissionScheduler({
- now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
- onAdmissionEvent: (event) => admissionEvents.push(event)
- })
- )
- const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`))
- tempRoots.push(root)
- const stateDir = path.join(root, 'state')
- await mkdir(stateDir)
- const binDir = await createStubGit(root)
- const repoDirs = await Promise.all(
- Array.from({ length: 6 }, async (_, index) => {
- const repoDir = path.join(root, `repo-${index}`)
- await mkdir(repoDir)
- return repoDir
- })
- )
- const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
- const startedAt = performance.now()
- const drifts: number[] = []
- let nextJankSample = performance.now() + 50
- const jankTimer = setInterval(() => {
- const now = performance.now()
- drifts.push(Math.max(0, now - nextJankSample))
- nextJankSample = now + 50
- }, 50)
- let maxConcurrentChildren = 0
- const censusTimer = setInterval(() => {
- void liveChildCount(stateDir).then((count) => {
- maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
- })
- }, 5)
-
- const background = Array.from({ length: 60 }, (_, index) =>
- gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
- cwd: repoDirs[index % repoDirs.length],
- env: {
- ...baseEnv,
- ORCA_STUB_ID: `background-${index}`,
- ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
- ORCA_STUB_STATE_DIR: stateDir
- },
- admissionTier: 'background'
- })
- )
+ await chmod(stub, 0o755)
+ const controller = new AbortController()
+ const commands = new Map>()
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
- const interactiveLatencies = await Promise.all(
- Array.from(
- { length: 10 },
- (_, index) =>
- new Promise((resolve, reject) => {
- setTimeout(
- () => {
- void (async () => {
- const concurrentAtInjection = await liveChildCount(stateDir)
- const commandStartedAt = performance.now()
- const commandLabel = `interactive-${index}`
- interactiveQueueSnapshots.push({
- commandLabel,
- backgroundWaiterIds: _gitAdmissionSnapshotForTests()
- .queuedWaiters.filter((waiter) => waiter.tier === 'background')
- .map((waiter) => waiter.id)
- })
- await gitExecFileAsync(['rev-parse', commandLabel], {
- cwd: repoDirs[index % repoDirs.length],
- env: {
- ...baseEnv,
- ORCA_STUB_ID: `interactive-${index}`,
- ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
- ORCA_STUB_STATE_DIR: stateDir
- },
- admissionTier: 'interactive'
- })
- resolve(performance.now() - commandStartedAt)
- })().catch(reject)
- },
- 50 * (index + 1)
- )
- })
- )
- )
- await Promise.all(background)
- clearInterval(censusTimer)
- clearInterval(jankTimer)
- maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
- const totalWallMs = performance.now() - startedAt
- return {
- mode,
- maxConcurrentChildren,
- eventLoopMaxDriftMs: Math.max(0, ...drifts),
- eventLoopP99DriftMs: percentile(drifts, 0.99),
- interactiveP50Ms: percentile(interactiveLatencies, 0.5),
- interactiveP95Ms: percentile(interactiveLatencies, 0.95),
- totalWallMs,
- admissionEvents,
- interactiveQueueSnapshots
- }
-}
-
-function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
- const rounded = rows.map((row) => ({
- mode: row.mode,
- maxConcurrentChildren: row.maxConcurrentChildren,
- eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
- eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
- interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
- interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
- totalWallMs: row.totalWallMs.toFixed(1)
- }))
- return JSON.stringify(rounded)
-}
-
-function assertAdmissionLedger(measurement: StormMeasurement): void {
- const activeWaiters = new Set()
- const grantSequenceByWaiter = new Map()
- const grantByLabel = new Map()
-
- measurement.admissionEvents.forEach((event, index) => {
- expect(event.sequence).toBe(index)
- if (event.phase === 'grant') {
- activeWaiters.add(event.waiterId)
- grantSequenceByWaiter.set(event.waiterId, event.sequence)
- const label = event.args.find((arg) => arg.startsWith('interactive-'))
- if (label) {
- grantByLabel.set(label, event)
+ const start = (id: string, tier: 'background' | 'interactive'): void => {
+ if (tier === 'interactive') {
+ interactiveQueueSnapshots.push({
+ commandLabel: id,
+ backgroundWaiterIds: _gitAdmissionSnapshotForTests()
+ .queuedWaiters.filter((waiter) => waiter.tier === 'background')
+ .map((waiter) => waiter.id)
+ })
+ }
+ const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], {
+ cwd: root,
+ admissionTier: tier,
+ signal: controller.signal,
+ env: {
+ ...process.env,
+ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
+ ORCA_STUB_ID: id,
+ ORCA_STUB_STATE_DIR: stateDir,
+ ORCA_STUB_GATE_DIR: gateDir
}
- } else {
- expect(activeWaiters.delete(event.waiterId)).toBe(true)
+ })
+ void command.catch(() => {})
+ commands.set(id, command)
+ }
+ const release = async (id: string): Promise => {
+ await waitForLiveChild(stateDir, id, controller.signal)
+ await writeFile(path.join(gateDir, id), 'release\n')
+ await expect(commands.get(id)).resolves.toEqual({
+ stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`,
+ stderr: ''
+ })
+ }
+ try {
+ backgroundIds.forEach((id) => start(id, 'background'))
+ expect(_gitAdmissionSnapshotForTests().queued).toBe(4)
+ headroomIds.forEach((id) => start(id, 'interactive'))
+ queuedIds.forEach((id) => start(id, 'interactive'))
+ expect(_gitAdmissionSnapshotForTests()).toMatchObject({
+ queued: 6,
+ budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } }
+ })
+ await Promise.all(
+ [...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) =>
+ waitForLiveChild(stateDir, id, controller.signal)
+ )
+ )
+ expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM)
+ for (const [index, id] of queuedIds.entries()) {
+ await release(backgroundIds[index])
+ await waitForLiveChild(stateDir, id, controller.signal)
}
- expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
- for (const budget of event.budgets) {
- expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
- expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
+ expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([
+ 'background',
+ 'background',
+ 'background',
+ 'background'
+ ])
+ for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) {
+ await release(id)
}
- })
- expect(activeWaiters.size).toBe(0)
-
- for (const snapshot of measurement.interactiveQueueSnapshots) {
- const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
- expect(interactiveGrant).toBeDefined()
- const preceded = snapshot.backgroundWaiterIds.filter(
- (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
- ).length
- expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
+ expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length)
+ expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length)
+ assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots })
+ expect(_gitAdmissionSnapshotForTests()).toMatchObject({
+ queued: 0,
+ budgets: { general: { baseUsed: 0, headroomUsed: 0 } }
+ })
+ expect(await readdir(stateDir)).toEqual([])
+ } finally {
+ controller.abort()
+ await Promise.allSettled(commands.values())
}
}
describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => {
- it('reports bounded-concurrency before and after measurements', async () => {
- const disabled = await measureStorm('disabled')
- const enabled = await measureStorm('enabled')
- console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
- assertAdmissionLedger(enabled)
- })
+ it(
+ 'bounds real children and drains interactive work before the background backlog',
+ verifySpawnContention
+ )
- // Output parity lives in git-admission-output-parity.test.ts: it needs real git,
- // not the PATH stub, so it runs on win32 too and cannot share this file's gate.
+ it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')(
+ 'reports bounded-concurrency before and after measurements',
+ async () => {
+ const disabled = await measureStorm('disabled')
+ const enabled = await measureStorm('enabled')
+ console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
+ assertAdmissionLedger(enabled)
+ }
+ )
+ // Real-git output parity remains in git-admission-output-parity.test.ts, including Windows.
})
diff --git a/src/main/git/command-runner/git-admission-storm-test-fixture.ts b/src/main/git/command-runner/git-admission-storm-test-fixture.ts
new file mode 100644
index 00000000000..4ee1219f027
--- /dev/null
+++ b/src/main/git/command-runner/git-admission-storm-test-fixture.ts
@@ -0,0 +1,245 @@
+/**
+ * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
+ * Timing distributions are reported for field comparison; CI assertions stay structural.
+ */
+import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, expect } from 'vitest'
+import { gitExecFileAsync } from './git-exec-file'
+import {
+ GIT_ADMISSION_AGING_MS,
+ GitAdmissionScheduler,
+ MAX_GIT_CHILDREN,
+ _gitAdmissionSnapshotForTests,
+ _resetGitAdmissionForTests,
+ type GitAdmissionEvent
+} from './git-subprocess-admission'
+
+type StormMeasurement = {
+ mode: 'disabled' | 'enabled'
+ maxConcurrentChildren: number
+ eventLoopMaxDriftMs: number
+ eventLoopP99DriftMs: number
+ interactiveP50Ms: number
+ interactiveP95Ms: number
+ totalWallMs: number
+ admissionEvents: GitAdmissionEvent[]
+ interactiveQueueSnapshots: InteractiveQueueSnapshot[]
+}
+
+export type InteractiveQueueSnapshot = {
+ commandLabel: string
+ backgroundWaiterIds: number[]
+}
+
+const tempRoots: string[] = []
+const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
+
+afterEach(async () => {
+ if (originalAdmissionDisabled === undefined) {
+ delete process.env.ORCA_GIT_ADMISSION_DISABLED
+ } else {
+ process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
+ }
+ _resetGitAdmissionForTests()
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+export async function createStormRoot(label: string): Promise {
+ const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`))
+ tempRoots.push(root)
+ return root
+}
+
+function percentile(values: readonly number[], percentileValue: number): number {
+ if (values.length === 0) {
+ return 0
+ }
+ const sorted = [...values].sort((a, b) => a - b)
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
+}
+
+async function liveChildCount(stateDir: string): Promise {
+ return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
+}
+
+async function createStubGit(root: string): Promise {
+ const binDir = path.join(root, 'bin')
+ await mkdir(binDir)
+ const stubPath = path.join(binDir, 'git')
+ await writeFile(
+ stubPath,
+ `#!/bin/sh
+set -eu
+live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
+: > "$live"
+trap 'rm -f "$live"' EXIT HUP INT TERM
+sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
+printf 'stub:%s\\n' "$*"
+`
+ )
+ await chmod(stubPath, 0o755)
+ return binDir
+}
+
+function setAdmissionMode(mode: StormMeasurement['mode']): void {
+ if (mode === 'disabled') {
+ process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
+ } else {
+ delete process.env.ORCA_GIT_ADMISSION_DISABLED
+ }
+}
+
+export async function measureStorm(mode: StormMeasurement['mode']): Promise {
+ setAdmissionMode(mode)
+ const admissionEvents: GitAdmissionEvent[] = []
+ const admissionClockStartedAt = performance.now()
+ _resetGitAdmissionForTests(
+ new GitAdmissionScheduler({
+ now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
+ onAdmissionEvent: (event) => admissionEvents.push(event)
+ })
+ )
+ const root = await createStormRoot(mode)
+ const stateDir = path.join(root, 'state')
+ await mkdir(stateDir)
+ const binDir = await createStubGit(root)
+ const repoDirs = await Promise.all(
+ Array.from({ length: 6 }, async (_, index) => {
+ const repoDir = path.join(root, `repo-${index}`)
+ await mkdir(repoDir)
+ return repoDir
+ })
+ )
+ const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
+ const startedAt = performance.now()
+ const drifts: number[] = []
+ let nextJankSample = performance.now() + 50
+ const jankTimer = setInterval(() => {
+ const now = performance.now()
+ drifts.push(Math.max(0, now - nextJankSample))
+ nextJankSample = now + 50
+ }, 50)
+ let maxConcurrentChildren = 0
+ const censusTimer = setInterval(() => {
+ void liveChildCount(stateDir).then((count) => {
+ maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
+ })
+ }, 5)
+
+ const background = Array.from({ length: 60 }, (_, index) =>
+ gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
+ cwd: repoDirs[index % repoDirs.length],
+ env: {
+ ...baseEnv,
+ ORCA_STUB_ID: `background-${index}`,
+ ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
+ ORCA_STUB_STATE_DIR: stateDir
+ },
+ admissionTier: 'background'
+ })
+ )
+ const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
+ const interactiveLatencies = await Promise.all(
+ Array.from(
+ { length: 10 },
+ (_, index) =>
+ new Promise((resolve, reject) => {
+ setTimeout(
+ () => {
+ void (async () => {
+ const concurrentAtInjection = await liveChildCount(stateDir)
+ const commandStartedAt = performance.now()
+ const commandLabel = `interactive-${index}`
+ interactiveQueueSnapshots.push({
+ commandLabel,
+ backgroundWaiterIds: _gitAdmissionSnapshotForTests()
+ .queuedWaiters.filter((waiter) => waiter.tier === 'background')
+ .map((waiter) => waiter.id)
+ })
+ await gitExecFileAsync(['rev-parse', commandLabel], {
+ cwd: repoDirs[index % repoDirs.length],
+ env: {
+ ...baseEnv,
+ ORCA_STUB_ID: `interactive-${index}`,
+ ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
+ ORCA_STUB_STATE_DIR: stateDir
+ },
+ admissionTier: 'interactive'
+ })
+ resolve(performance.now() - commandStartedAt)
+ })().catch(reject)
+ },
+ 50 * (index + 1)
+ )
+ })
+ )
+ )
+ await Promise.all(background)
+ clearInterval(censusTimer)
+ clearInterval(jankTimer)
+ maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
+ const totalWallMs = performance.now() - startedAt
+ return {
+ mode,
+ maxConcurrentChildren,
+ eventLoopMaxDriftMs: Math.max(0, ...drifts),
+ eventLoopP99DriftMs: percentile(drifts, 0.99),
+ interactiveP50Ms: percentile(interactiveLatencies, 0.5),
+ interactiveP95Ms: percentile(interactiveLatencies, 0.95),
+ totalWallMs,
+ admissionEvents,
+ interactiveQueueSnapshots
+ }
+}
+
+export function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
+ const rounded = rows.map((row) => ({
+ mode: row.mode,
+ maxConcurrentChildren: row.maxConcurrentChildren,
+ eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
+ eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
+ interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
+ interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
+ totalWallMs: row.totalWallMs.toFixed(1)
+ }))
+ return JSON.stringify(rounded)
+}
+
+export function assertAdmissionLedger(
+ measurement: Pick
+): void {
+ const activeWaiters = new Set()
+ const grantSequenceByWaiter = new Map()
+ const grantByLabel = new Map()
+
+ measurement.admissionEvents.forEach((event, index) => {
+ expect(event.sequence).toBe(index)
+ if (event.phase === 'grant') {
+ activeWaiters.add(event.waiterId)
+ grantSequenceByWaiter.set(event.waiterId, event.sequence)
+ const label = event.args.find((arg) => arg.startsWith('interactive-'))
+ if (label) {
+ grantByLabel.set(label, event)
+ }
+ } else {
+ expect(activeWaiters.delete(event.waiterId)).toBe(true)
+ }
+ expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
+ for (const budget of event.budgets) {
+ expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
+ expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
+ }
+ })
+ expect(activeWaiters.size).toBe(0)
+
+ for (const snapshot of measurement.interactiveQueueSnapshots) {
+ const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
+ expect(interactiveGrant).toBeDefined()
+ const preceded = snapshot.backgroundWaiterIds.filter(
+ (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
+ ).length
+ expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
+ }
+}
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index fa331749c8b..ed72bc12232 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -23,7 +23,11 @@ export {
type PullRequestPushTarget
} from './client/lookup/pull-request-push-target'
export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
-export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
+export {
+ getWorkItem,
+ getWorkItemByOwnerRepo,
+ getWorkItemWithRepository
+} from './client/fetch/get-work-item'
export { listWorkItems } from './client/list/list-work-items'
export { mergePR } from './client/merge/merge-pr'
export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts
index 3178a4c921a..5224061dfa3 100644
--- a/src/main/github/client/fetch/get-work-item.ts
+++ b/src/main/github/client/fetch/get-work-item.ts
@@ -21,6 +21,30 @@ export async function getWorkItem(
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise {
+ const { item } = await getWorkItemWithRepository(
+ repoPath,
+ number,
+ type,
+ connectionId,
+ localGitOptions,
+ preference
+ )
+ return item
+}
+
+export type WorkItemRepositoryLookup = {
+ item: MainWorkItem | null
+ repository: GitHubApiRepository | null
+}
+
+export async function getWorkItemWithRepository(
+ repoPath: string,
+ number: number,
+ type?: 'issue' | 'pr',
+ connectionId?: string | null,
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference
+): Promise {
await acquire()
try {
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
@@ -35,18 +59,24 @@ export async function getWorkItem(
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
// (same fail-closed rule as origin-pinned PR candidate resolution).
if (!source && preference === 'origin') {
- return null
+ return { item: null, repository: null }
+ }
+ return {
+ item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions),
+ repository: source
}
- return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
}
if (type === 'pr') {
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
+ return {
+ item: await fetchPullRequestWorkItemFromCandidates(
+ repoPath,
+ number,
+ connectionId,
+ localGitOptions,
+ preference
+ ),
+ repository: null
+ }
}
try {
@@ -65,7 +95,7 @@ export async function getWorkItem(
localGitOptions
)
if (issue) {
- return issue
+ return { item: issue, repository: source }
}
}
} catch (err) {
@@ -75,15 +105,18 @@ export async function getWorkItem(
throw err
}
}
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
+ return {
+ item: await fetchPullRequestWorkItemFromCandidates(
+ repoPath,
+ number,
+ connectionId,
+ localGitOptions,
+ preference
+ ),
+ repository: null
+ }
} catch {
- return null
+ return { item: null, repository: null }
} finally {
release()
}
diff --git a/src/main/github/github-api-repository-validation.test.ts b/src/main/github/github-api-repository-validation.test.ts
index 81e395a8bdb..7f8eec06602 100644
--- a/src/main/github/github-api-repository-validation.test.ts
+++ b/src/main/github/github-api-repository-validation.test.ts
@@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => {
expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false)
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false)
})
+
+ it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => {
+ expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false)
+ })
+
+ it.each([undefined, 'github.com', 'github.enterprise.test:8443'])(
+ 'accepts supported legacy or explicit repository host types: %j',
+ (host) => {
+ expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true)
+ }
+ )
+
+ it.each([
+ null,
+ 'acme/orca',
+ { owner: 42, repo: 'orca' },
+ { owner: 'acme', repo: 42 },
+ { owner: 'acme' }
+ ])('rejects malformed runtime repository identities: %j', (repository) => {
+ expect(isValidGitHubApiRepository(repository)).toBe(false)
+ })
})
diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts
index b687a0ebdee..4031e64db13 100644
--- a/src/main/github/github-api-repository-validation.ts
+++ b/src/main/github/github-api-repository-validation.ts
@@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution =
const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
-export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
+export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo {
+ if (
+ !repository ||
+ typeof repository !== 'object' ||
+ !('owner' in repository) ||
+ !('repo' in repository)
+ ) {
+ return false
+ }
return (
+ typeof repository.owner === 'string' &&
+ typeof repository.repo === 'string' &&
+ (!('host' in repository) ||
+ repository.host === undefined ||
+ typeof repository.host === 'string') &&
OWNER_SLUG_RE.test(repository.owner) &&
REPOSITORY_SLUG_RE.test(repository.repo) &&
repository.repo !== '.' &&
diff --git a/src/main/github/issue-comment.ts b/src/main/github/issue-comment.ts
index ac3f6a205de..478ac1ce68c 100644
--- a/src/main/github/issue-comment.ts
+++ b/src/main/github/issue-comment.ts
@@ -1,32 +1,34 @@
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
+import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
-import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
+import {
+ resolveGitHubRepoExecution,
+ resolveIssueGitHubApiRepositorySource
+} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
-/**
- * Add a comment to an existing GitHub issue.
- *
- * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`):
- * a comment is posted against an issue number already bound to a worktree or
- * surfaced from a prior read. Routing through the live per-repo preference
- * would let a user read upstream#N, toggle the selector to origin, and have
- * their reply silently post on origin#N — a different issue entirely. That
- * is the same silent-source-switch class of wrongness #1186 / the parent
- * design doc guard against. List and create paths honor preference;
- * mutations stay on the heuristic `getIssueOwnerRepo`.
- */
+// An explicit target keeps replies bound to the conversation; otherwise use the selected source.
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string,
connectionId?: string | null,
ownerRepoOverride?: OwnerRepo | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
ownerRepoOverride ??
- (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)),
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts
index a4d2aae32f4..77e2337ae8a 100644
--- a/src/main/github/issue-field-options.ts
+++ b/src/main/github/issue-field-options.ts
@@ -1,4 +1,4 @@
-import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
+import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import {
@@ -11,19 +11,21 @@ export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ repositoryOverride?: GitHubOwnerRepo | null
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- async () =>
- (
- await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- ).source,
+ repositoryOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
@@ -57,19 +59,21 @@ export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ repositoryOverride?: GitHubOwnerRepo | null
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- async () =>
- (
- await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- ).source,
+ repositoryOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/issue-mutation-source.test.ts b/src/main/github/issue-mutation-source.test.ts
new file mode 100644
index 00000000000..48967198fd1
--- /dev/null
+++ b/src/main/github/issue-mutation-source.test.ts
@@ -0,0 +1,178 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as GithubApiRepositoryModule from './github-api-repository'
+import type * as GhUtils from './gh-utils'
+
+const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(
+ () => ({
+ ghExecFileAsyncMock: vi.fn(),
+ resolveIssueSourceMock: vi.fn(),
+ acquireMock: vi.fn(),
+ releaseMock: vi.fn()
+ })
+)
+
+vi.mock('./gh-utils', async () => {
+ const actual = await vi.importActual('./gh-utils')
+ return {
+ ...actual,
+ ghExecFileAsync: ghExecFileAsyncMock,
+ acquire: acquireMock,
+ release: releaseMock
+ }
+})
+
+vi.mock('./github-api-repository', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock
+}))
+
+import { addIssueComment } from './issue-comment'
+import { updateIssue } from './issue-update'
+
+const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
+
+describe('issue mutations follow the issue source preference', () => {
+ beforeEach(() => {
+ ghExecFileAsyncMock.mockReset()
+ acquireMock.mockReset()
+ acquireMock.mockResolvedValue(undefined)
+ releaseMock.mockReset()
+ resolveIssueSourceMock.mockReset()
+ resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({
+ source: preference === 'origin' ? ORIGIN : UPSTREAM,
+ fellBack: false
+ }))
+ })
+
+ it('posts issue comments to origin when origin is selected', async () => {
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
+ })
+
+ await expect(
+ addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: true })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
+ 'repos/fork-owner/widgets/issues/5/comments'
+ )
+ })
+
+ it('keeps an explicit owner/repo override ahead of the preference', async () => {
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
+ })
+
+ await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin')
+
+ expect(resolveIssueSourceMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
+ 'repos/upstream-owner/widgets/issues/7/comments'
+ )
+ })
+
+ it('edits issues on origin when origin is selected', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await expect(
+ updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin')
+ ).resolves.toEqual({ ok: true })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5')
+ expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
+ 'issue',
+ 'edit',
+ '5',
+ '--repo',
+ 'fork-owner/widgets',
+ '--add-assignee',
+ 'octo'
+ ])
+ })
+
+ it('keeps the upstream-first default when no preference is set', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue('/repo-root', 5, { body: 'Updated' })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5')
+ })
+
+ it('keeps edits on the opened issue when the saved preference changes', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue(
+ '/repo-root',
+ 5,
+ { body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] },
+ null,
+ {},
+ 'upstream',
+ ORIGIN
+ )
+
+ expect(resolveIssueSourceMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
+ ['issue', 'close', '5', '--repo', 'fork-owner/widgets'],
+ [
+ 'api',
+ '-X',
+ 'PATCH',
+ 'repos/fork-owner/widgets/issues/5',
+ '--raw-field',
+ 'body=Origin edit'
+ ],
+ [
+ 'issue',
+ 'edit',
+ '5',
+ '--repo',
+ 'fork-owner/widgets',
+ '--add-label',
+ 'bug',
+ '--add-assignee',
+ 'octo'
+ ]
+ ])
+ })
+
+ it('preserves local WSL execution with an explicit issue repository', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue(
+ '/home/fixture/widgets',
+ 5,
+ { body: 'Origin edit' },
+ null,
+ { wslDistro: 'Ubuntu' },
+ 'upstream',
+ ORIGIN
+ )
+
+ expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
+ expect.arrayContaining(['repos/fork-owner/widgets/issues/5']),
+ expect.objectContaining({
+ cwd: '/home/fixture/widgets',
+ wslDistro: 'Ubuntu',
+ host: 'github.com'
+ })
+ )
+ })
+
+ it('refuses unresolved Origin mutations instead of using ambient gh', async () => {
+ resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false })
+
+ await expect(
+ updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: false })
+ await expect(
+ addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: false })
+
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts
index 801c12b79c5..8b00b24a148 100644
--- a/src/main/github/issue-update.ts
+++ b/src/main/github/issue-update.ts
@@ -1,31 +1,35 @@
import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
+import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
-import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
+import {
+ resolveGitHubRepoExecution,
+ resolveIssueGitHubApiRepositorySource,
+ type GitHubApiRepository
+} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
-/**
- * Update an existing GitHub issue. Fans out to separate gh commands for
- * state changes vs field edits since `gh issue edit` does not support state.
- *
- * Why this path doesn't take a preference (mirrors `getIssue`): mutations
- * target an issue number already bound to a worktree / linked elsewhere in
- * the UI. Routing an update through the live per-repo preference would let
- * a user open upstream#N, toggle the selector to origin, save, and silently
- * write to origin#N — a different issue (or 404). That is the exact
- * silent-source-switch class of wrongness #1186 / the parent design doc
- * guard against. List and create paths honor preference; mutations stay on
- * the heuristic `getIssueOwnerRepo`.
- */
+// Use the selected source for issue-number edits.
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference,
+ ownerRepoOverride?: GitHubApiRepository | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
+ ownerRepoOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts
index 3fdbacb6491..d7a0671470e 100644
--- a/src/main/github/work-item-details-concurrency.test.ts
+++ b/src/main/github/work-item-details-concurrency.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({
})
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getPRComments: getPRCommentsMock,
getPRChecks: getPRChecksMock
}))
diff --git a/src/main/github/work-item-details-config-change.test.ts b/src/main/github/work-item-details-config-change.test.ts
new file mode 100644
index 00000000000..f10ecfe0046
--- /dev/null
+++ b/src/main/github/work-item-details-config-change.test.ts
@@ -0,0 +1,207 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import type * as NodeOs from 'node:os'
+import type * as GitExecError from '../git/exec-error'
+import type * as WorkItemLookup from './client/fetch/get-work-item'
+import { runProcess } from '../../shared/child-process/run-process'
+
+const { ghMock, fixture } = vi.hoisted(() => {
+ const fixture: {
+ path: string
+ initialHost: string
+ replacementUrl: string
+ requests: { args: string[]; host?: string }[]
+ } = { path: '', initialHost: '', replacementUrl: '', requests: [] }
+ return { ghMock: vi.fn(), fixture }
+})
+
+vi.mock('node:os', async (original) => ({
+ ...(await original()),
+ homedir: () => join(fixture.path, 'isolated-home')
+}))
+
+vi.mock('../git/runner', async () => {
+ const errors = await vi.importActual('../git/exec-error')
+ return {
+ ...errors,
+ ghExecFileAsync: ghMock,
+ gitExecFileAsync: async (args: string[], options: { cwd?: string }) => {
+ const result = await runProcess({
+ program: 'git',
+ args,
+ cwd: options.cwd,
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
+ }
+ })
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+ return { stdout: result.stdout, stderr: result.stderr }
+ }
+ }
+})
+
+vi.mock('./client', async () => ({
+ ...(await vi.importActual('./client/fetch/get-work-item')),
+ getPRChecks: vi.fn(),
+ getPRComments: vi.fn()
+}))
+
+vi.mock('./rate-limit', () => ({
+ repositoryRateLimitGuard: () => ({ blocked: false }),
+ noteRepositoryRateLimitSpend: vi.fn()
+}))
+
+import { getWorkItemDetails } from './work-item-details'
+import { _resetOwnerRepoCache } from './github-repository-identity'
+import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+import { _resetGitHubHostAuthCache } from './github-enterprise-repository'
+
+async function fixtureGit(args: string[]): Promise {
+ const result = await runProcess({
+ program: 'git',
+ args,
+ cwd: fixture.path,
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
+ }
+ })
+ expect(result.code, result.stderr).toBe(0)
+}
+
+beforeEach(async () => {
+ fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-'))
+ await mkdir(join(fixture.path, 'isolated-home'))
+ await fixtureGit(['init', '--quiet'])
+ await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git'])
+ _resetOwnerRepoCache()
+ _resetOriginGitHubApiRepositoryCache()
+ _resetGitHubHostAuthCache()
+ fixture.initialHost = 'github.com'
+ fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git'
+ fixture.requests = []
+ ghMock.mockReset()
+ ghMock.mockImplementation(async (args: string[], options: { host?: string }) => {
+ fixture.requests.push({ args, host: options.host })
+ if (args[0] === 'auth' && args[1] === 'status') {
+ return {
+ stdout: ['github.com', 'github.enterprise.test']
+ .map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`)
+ .join('\n'),
+ stderr: ''
+ }
+ }
+ if (args.includes('repos/fork-owner/widgets/issues/5')) {
+ await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl])
+ return {
+ stdout: JSON.stringify({
+ number: 5,
+ title: 'ORIGIN title',
+ state: 'open',
+ html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`,
+ labels: [],
+ updated_at: '2026-10-02T00:00:00Z',
+ user: { login: 'fork-author' }
+ }),
+ stderr: ''
+ }
+ }
+ if (args.includes('graphql')) {
+ const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost
+ const marker = original ? 'ORIGIN' : 'REPLACEMENT'
+ return {
+ stdout: JSON.stringify({
+ data: {
+ repository: {
+ issue: {
+ body: `${marker} body`,
+ assignees: { nodes: [{ login: `${marker}-assignee` }] },
+ participants: { nodes: [] },
+ comments: {
+ nodes: [
+ {
+ databaseId: 1,
+ body: `${marker} comment`,
+ createdAt: '2026-10-02T00:00:00Z',
+ url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`,
+ author: { login: `${marker}-author`, avatarUrl: '' }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }),
+ stderr: ''
+ }
+ }
+ if (args.some((arg) => arg.includes('/timeline?'))) {
+ return { stdout: '[]', stderr: '' }
+ }
+ throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`)
+ })
+})
+
+afterEach(async () => {
+ await rm(fixture.path, { recursive: true, force: true })
+})
+
+it.each(['issue', undefined] as const)(
+ 'keeps implicit %s conversation on the repository used for the item fetch',
+ async (type) => {
+ const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin')
+ expect(details?.item.title).toBe('ORIGIN title')
+ expect(details?.body).toBe('ORIGIN body')
+ expect(details?.assignees).toEqual(['ORIGIN-assignee'])
+ expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
+ expect(fixture.requests[1].args).toContain('owner=fork-owner')
+ }
+)
+
+it('keeps an explicit local Tasks identity through the same real config change', async () => {
+ const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
+ owner: 'fork-owner',
+ repo: 'widgets',
+ host: 'github.com'
+ })
+ expect(details?.item.title).toBe('ORIGIN title')
+ expect(details?.body).toBe('ORIGIN body')
+})
+
+it.each([
+ { from: 'github.com', to: 'github.enterprise.test', qualified: false },
+ { from: 'github.enterprise.test', to: 'github.com', qualified: false },
+ { from: 'github.com', to: 'github.enterprise.test', qualified: true },
+ { from: 'github.enterprise.test', to: 'github.com', qualified: true }
+])(
+ 'keeps explicit host from $from to $to (qualified=$qualified)',
+ async ({ from, to, qualified }) => {
+ fixture.initialHost = from
+ fixture.replacementUrl = `https://${to}/fork-owner/widgets.git`
+ await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`])
+ const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
+ owner: 'fork-owner',
+ repo: 'widgets',
+ ...(qualified ? { host: from } : {})
+ })
+ expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`)
+ expect(details?.body).toBe('ORIGIN body')
+ expect(details?.assignees).toEqual(['ORIGIN-assignee'])
+ expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
+ const conversationRequests = fixture.requests.filter(
+ ({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?'))
+ )
+ expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true)
+ expect(
+ conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?')))
+ ).toBe(true)
+ expect(conversationRequests.map(({ host }) => host)).toEqual([from, from])
+ }
+)
diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts
index be2576051b5..b615f4e566b 100644
--- a/src/main/github/work-item-details-enterprise-host.test.ts
+++ b/src/main/github/work-item-details-enterprise-host.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts
index 6075803493c..7cbfa041608 100644
--- a/src/main/github/work-item-details-file-viewed.test.ts
+++ b/src/main/github/work-item-details-file-viewed.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-issue-source.test.ts b/src/main/github/work-item-details-issue-source.test.ts
new file mode 100644
index 00000000000..e52de876d18
--- /dev/null
+++ b/src/main/github/work-item-details-issue-source.test.ts
@@ -0,0 +1,239 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ ghExecFileAsyncMock,
+ getOwnerRepoMock,
+ getOwnerRepoForRemoteMock,
+ getWorkItemMock,
+ getWorkItemByOwnerRepoMock,
+ acquireMock,
+ releaseMock
+} = vi.hoisted(() => ({
+ ghExecFileAsyncMock: vi.fn(),
+ getOwnerRepoMock: vi.fn(),
+ getOwnerRepoForRemoteMock: vi.fn(),
+ getWorkItemMock: vi.fn(),
+ getWorkItemByOwnerRepoMock: vi.fn(),
+ acquireMock: vi.fn(),
+ releaseMock: vi.fn()
+}))
+
+vi.mock('./gh-utils', () => ({
+ ghExecFileAsync: ghExecFileAsyncMock,
+ getOwnerRepo: getOwnerRepoMock,
+ getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
+ ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
+ githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
+ repoPath,
+ connectionId: connectionId ?? null,
+ ...localGitOptions
+ })),
+ acquire: acquireMock,
+ release: releaseMock
+}))
+
+vi.mock('./client', async () => ({
+ getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
+ getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
+ getPRChecks: vi.fn(),
+ getPRComments: vi.fn()
+}))
+
+vi.mock('./github-enterprise-repository', () => ({
+ getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null),
+ getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null),
+ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
+}))
+
+vi.mock('./rate-limit', () => ({
+ repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
+ noteRepositoryRateLimitSpend: vi.fn()
+}))
+
+vi.mock('../git/remote-name-listing', () => ({
+ shouldProbeGitRemote: vi.fn().mockResolvedValue(true)
+}))
+
+import { getWorkItemDetails } from './work-item-details'
+import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+
+const REMOTES: Record = {
+ origin: { owner: 'fork-owner', repo: 'widgets' },
+ upstream: { owner: 'upstream-owner', repo: 'widgets' }
+}
+
+function graphQLIssueResponse(assignee: string): { stdout: string } {
+ return {
+ stdout: JSON.stringify({
+ data: {
+ repository: {
+ issue: {
+ body: `${assignee} body`,
+ assignees: { nodes: [{ login: assignee }] },
+ participants: { nodes: [] },
+ comments: { nodes: [] }
+ }
+ }
+ }
+ })
+ }
+}
+
+function graphQLOwnerArg(): string | undefined {
+ const args: string[] = ghExecFileAsyncMock.mock.calls[0][0]
+ return args.find((arg) => arg.startsWith('owner='))
+}
+
+describe('getWorkItemDetails issue source', () => {
+ beforeEach(() => {
+ _resetOriginGitHubApiRepositoryCache()
+ ghExecFileAsyncMock.mockReset()
+ getOwnerRepoMock.mockReset()
+ getOwnerRepoMock.mockResolvedValue(REMOTES.origin)
+ getOwnerRepoForRemoteMock.mockReset()
+ getOwnerRepoForRemoteMock.mockImplementation(
+ async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null
+ )
+ getWorkItemMock.mockReset()
+ getWorkItemByOwnerRepoMock.mockReset()
+ getWorkItemMock.mockResolvedValue({
+ id: 'issue:5',
+ type: 'issue',
+ number: 5,
+ title: 'Origin issue',
+ state: 'open',
+ url: 'https://github.com/fork-owner/widgets/issues/5',
+ labels: [],
+ updatedAt: '2026-04-01T00:00:00Z',
+ author: 'fork-author'
+ })
+ acquireMock.mockReset()
+ acquireMock.mockResolvedValue(undefined)
+ releaseMock.mockReset()
+ })
+
+ it('reads body, assignees and comments from origin when origin is selected', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin')
+
+ expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin')
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
+ 'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1'
+ )
+ expect(details?.assignees).toEqual(['fork-assignee'])
+ })
+
+ it('keeps the upstream-first default when no preference is set', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {})
+
+ expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
+ expect(details?.assignees).toEqual(['upstream-assignee'])
+ })
+
+ it('keeps a Project row on upstream even when Origin is selected', async () => {
+ const upstream = { ...REMOTES.upstream, host: 'github.com' }
+ getWorkItemByOwnerRepoMock.mockResolvedValue({
+ id: 'issue:5',
+ type: 'issue',
+ number: 5,
+ title: 'Upstream project issue',
+ url: 'https://github.com/upstream-owner/widgets/issues/5',
+ state: 'open',
+ labels: [],
+ updatedAt: '',
+ author: 'upstream-author'
+ })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream)
+
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
+ '/repo-root',
+ upstream,
+ 5,
+ 'issue',
+ null,
+ {}
+ )
+ expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
+ expect(details?.item.title).toBe('Upstream project issue')
+ expect(details?.assignees).toEqual(['upstream-assignee'])
+ })
+
+ it('does not fall back when the selected issue repository is unavailable', async () => {
+ getWorkItemByOwnerRepoMock.mockResolvedValue(null)
+
+ await expect(
+ getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
+ ...REMOTES.origin,
+ host: 'github.com'
+ })
+ ).resolves.toBeNull()
+
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects an invalid runtime issue host before calling the repository lookup', async () => {
+ await expect(
+ getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
+ ...REMOTES.origin,
+ host: 42
+ })
+ ).resolves.toBeNull()
+
+ expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('ignores an issue repository override on an untyped lookup', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', {
+ owner: 'untrusted',
+ repo: 'other',
+ host: 'untrusted.example'
+ })
+
+ expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
+ })
+
+ it('normalizes host-less issue targets before loading the conversation', async () => {
+ getWorkItemByOwnerRepoMock.mockResolvedValue({
+ type: 'issue',
+ number: 5,
+ title: 'Origin issue',
+ url: 'https://github.com/fork-owner/widgets/issues/5',
+ author: null
+ })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin)
+
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
+ expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' })
+ })
+})
diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts
index d0205332bd5..c7af8abac85 100644
--- a/src/main/github/work-item-details-pr-files.test.ts
+++ b/src/main/github/work-item-details-pr-files.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-test-lookup.ts b/src/main/github/work-item-details-test-lookup.ts
new file mode 100644
index 00000000000..69c1daf0777
--- /dev/null
+++ b/src/main/github/work-item-details-test-lookup.ts
@@ -0,0 +1,17 @@
+import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
+import type { getWorkItem } from './client'
+import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item'
+
+// Why: detail tests replace item lookup; retain its selected-source metadata.
+export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) {
+ return async (...args: Parameters): Promise => {
+ const item = await lookup(...args)
+ return {
+ item,
+ repository:
+ item?.type === 'issue'
+ ? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source
+ : null
+ }
+ }
+}
diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts
index 3ec1a5a4d9f..ef2d3649818 100644
--- a/src/main/github/work-item-details.test.ts
+++ b/src/main/github/work-item-details.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts
index dafc4f514d6..385d66c9515 100644
--- a/src/main/github/work-item-details.ts
+++ b/src/main/github/work-item-details.ts
@@ -1,14 +1,16 @@
import type { PRCheckDetail } from '../../shared/github/check-types'
import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types'
-import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types'
+import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
-import { getPRChecks, getPRComments, getWorkItem } from './client'
-import { acquire, release, type LocalGitExecOptions } from './gh-utils'
import {
- getIssueGitHubApiRepository,
- resolveGitHubRepoExecution,
- type GitHubApiRepository
-} from './github-api-repository'
+ getPRChecks,
+ getPRComments,
+ getWorkItemWithRepository,
+ getWorkItemByOwnerRepo
+} from './client'
+import { acquire, release, type LocalGitExecOptions } from './gh-utils'
+import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository'
+import { isValidGitHubApiRepository } from './github-api-repository-validation'
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
import {
getPRFiles,
@@ -69,23 +71,49 @@ export async function getWorkItemDetails(
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
- preference?: IssueSourcePreference
+ preference?: IssueSourcePreference,
+ repositoryOverride?: unknown
): Promise {
- const item: Omit | null = await getWorkItem(
- repoPath,
- number,
- type,
- connectionId,
- localGitOptions,
- preference
- )
+ let issueRepositoryOverride: GitHubApiRepository | null = null
+ if (type === 'issue' && repositoryOverride != null) {
+ if (!isValidGitHubApiRepository(repositoryOverride)) {
+ return null
+ }
+ issueRepositoryOverride = (
+ await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions)
+ ).ownerRepo
+ if (!issueRepositoryOverride) {
+ return null
+ }
+ }
+ const { item, repository: selectedRepository } = issueRepositoryOverride
+ ? {
+ item: await getWorkItemByOwnerRepo(
+ repoPath,
+ issueRepositoryOverride,
+ number,
+ 'issue',
+ connectionId,
+ localGitOptions
+ ),
+ repository: issueRepositoryOverride
+ }
+ : await getWorkItemWithRepository(
+ repoPath,
+ number,
+ type,
+ connectionId,
+ localGitOptions,
+ preference
+ )
if (!item) {
return null
}
+ // Keep the conversation in the same repository as the selected issue.
const resolvedRepository =
item.type === 'issue'
- ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
+ ? selectedRepository
: (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions))
.ownerRepo
diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts
index 907092515f5..664bbaf1aa3 100644
--- a/src/main/global-fetch-call-site-audit.test.ts
+++ b/src/main/global-fetch-call-site-audit.test.ts
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([
['main/bitbucket/client.ts', 1],
['main/bitbucket/user-request.ts', 1],
['main/gitea/client.ts', 1],
+ // Generated OpenCode claim source consumes JSON or cancels its body in finally.
+ ['main/opencode/opencode-startup-prompt-source.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
diff --git a/src/main/ipc/command-path-resolver.test.ts b/src/main/ipc/command-path-resolver.test.ts
index 9172d1c2465..9e1c9cd7d5c 100644
--- a/src/main/ipc/command-path-resolver.test.ts
+++ b/src/main/ipc/command-path-resolver.test.ts
@@ -1,8 +1,8 @@
-import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
+import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { isCommandOnLocalPath } from './command-path-resolver'
+import { isCommandOnLocalPath, listLocalCommandPaths } from './command-path-resolver'
describe('isCommandOnLocalPath', () => {
it('returns false for an empty command', async () => {
@@ -112,4 +112,129 @@ describe('isCommandOnLocalPath', () => {
).resolves.toBe(true)
})
})
+
+ // Why the full list exists (#22975): a dead version-manager shim passes the
+ // same executable check as the binary it shadows, so a winner-only lookup can
+ // only ever hand the caller the shim. The copies behind it are the answer.
+ describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => {
+ let front = ''
+ let middle = ''
+ let back = ''
+
+ async function executable(dir: string, name: string, body: string): Promise {
+ await writeFile(path.join(dir, name), body)
+ await chmod(path.join(dir, name), 0o755)
+ }
+
+ beforeAll(async () => {
+ front = await mkdtemp(path.join(tmpdir(), 'cmd-list-front-'))
+ middle = await mkdtemp(path.join(tmpdir(), 'cmd-list-middle-'))
+ back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-'))
+ await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n')
+ await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n")
+ // Both a non-executable file and a directory in the middle: neither is a
+ // match, and neither may end the scan.
+ await writeFile(path.join(middle, 'gh'), 'not executable\n')
+ await chmod(path.join(middle, 'gh'), 0o644)
+ await mkdir(path.join(middle, 'glab'))
+ await executable(back, 'glab', '#!/bin/sh\n')
+ })
+
+ afterAll(async () => {
+ await rm(front, { recursive: true, force: true })
+ await rm(middle, { recursive: true, force: true })
+ await rm(back, { recursive: true, force: true })
+ })
+
+ it('returns every match in PATH order, shim first', async () => {
+ await expect(
+ listLocalCommandPaths('gh', {
+ platform: 'linux',
+ env: { PATH: `${front}:${middle}:${back}` }
+ })
+ ).resolves.toEqual([path.posix.join(front, 'gh'), path.posix.join(back, 'gh')])
+ })
+
+ it('follows PATH order rather than which copy runs', async () => {
+ await expect(
+ listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: `${back}:${front}` } })
+ ).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
+ })
+
+ it('skips a directory that matches the command name and keeps scanning', async () => {
+ await expect(
+ listLocalCommandPaths('glab', {
+ platform: 'linux',
+ env: { PATH: `${middle}:${front}:${back}` }
+ })
+ ).resolves.toEqual([path.posix.join(back, 'glab')])
+ })
+
+ it('returns an empty list when PATH holds no absolute match', async () => {
+ await expect(
+ listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: '' } })
+ ).resolves.toEqual([])
+ })
+
+ it('applies the absolute-only gate to the whole list, not just the winner', async () => {
+ await expect(
+ listLocalCommandPaths('gh', {
+ platform: 'linux',
+ env: { PATH: `.${path.delimiter}${front}` }
+ })
+ ).resolves.toEqual([path.posix.join(front, 'gh')])
+ })
+
+ it('drops a relative match the filesystem would otherwise find', async () => {
+ // Why a second relative case: `path.posix.join('.', 'gh')` collapses to
+ // `gh`, so the case above is unaffected by deleting the gate. `fs.access`
+ // resolves a relative candidate against the process cwd, so only a PATH
+ // entry that really reaches a fixture proves the filter — and the `access`
+ // proves that reach first, making this fail loudly instead of going
+ // vacuous if the suite is ever run from a directory that hides it.
+ const relative = path.relative(process.cwd(), back)
+ await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined()
+ await expect(
+ listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: relative } })
+ ).resolves.toEqual([])
+ })
+
+ it('lists a PATH entry that repeats only once', async () => {
+ // Why it matters: callers spawn each entry, and `/usr/local/bin` appearing
+ // twice in a real PATH is normal — a doomed shim must not be probed twice.
+ await expect(
+ listLocalCommandPaths('gh', {
+ platform: 'linux',
+ env: { PATH: `${back}:${front}:${back}` }
+ })
+ ).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
+ })
+
+ it('returns an empty list for an empty command', async () => {
+ await expect(listLocalCommandPaths('')).resolves.toEqual([])
+ })
+
+ it('resolves an absolute command path directly', async () => {
+ await expect(
+ listLocalCommandPaths(path.join(front, 'gh'), { platform: 'linux', env: { PATH: '' } })
+ ).resolves.toEqual([`${front}/gh`])
+ })
+
+ it('lists every PATHEXT permutation on win32, in resolver order', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'cmd-list-win32-'))
+ try {
+ await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n')
+ await writeFile(path.join(dir, 'tool.EXE'), '')
+ await expect(
+ listLocalCommandPaths('tool', {
+ platform: 'win32',
+ env: { Path: dir, PATHEXT: '.CMD;.EXE' },
+ cwd: dir
+ })
+ ).resolves.toEqual([`${dir}/tool.CMD`, `${dir}/tool.EXE`])
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+ })
})
diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts
index 1f2934ba8b2..27808b6aded 100644
--- a/src/main/ipc/command-path-resolver.ts
+++ b/src/main/ipc/command-path-resolver.ts
@@ -1,4 +1,6 @@
import { access, constants as fsConstants, stat } from 'node:fs/promises'
+import { statSync, type Stats } from 'node:fs'
+import { homedir } from 'node:os'
import path from 'node:path'
export type ResolveCommandOptions = {
@@ -8,6 +10,8 @@ export type ResolveCommandOptions = {
env?: NodeJS.ProcessEnv
/** CWD used only for the win32 "search current directory first" rule. */
cwd?: string
+ /** Stop after this many matches; defaults to the complete list. */
+ maxResults?: number
}
// Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`,
@@ -38,6 +42,109 @@ function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[]
return extensions
}
+type LocalCommandSelection = {
+ scope: string
+ selected?: { binary: string; stamp: string; cwd?: string }
+}
+
+const localCommandSelections = new Map()
+
+function selectionScope(options: ResolveCommandOptions): string {
+ const platform = options.platform ?? process.platform
+ const env = options.env ?? process.env
+ const isWin = platform === 'win32'
+ const pathValue = readEnvCaseInsensitive(env, 'PATH') ?? ''
+ const pathApi = isWin ? path.win32 : path.posix
+ const needsCwd = pathValue.split(isWin ? ';' : ':').some((dir) => !pathApi.isAbsolute(dir))
+ return JSON.stringify([
+ platform,
+ pathValue,
+ isWin ? readEnvCaseInsensitive(env, 'PATHEXT') : null,
+ env.HOME,
+ env.USERPROFILE,
+ homedir(),
+ needsCwd ? (options.cwd ?? process.cwd()) : null
+ ])
+}
+
+function commandFileStamp(stats: Stats): string {
+ return [stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs, stats.mode].join(':')
+}
+
+/** Publish only a successful version probe; a newer probe supersedes an older one. */
+export function beginLocalCommandSelection(
+ command: string
+): (binary: string | null) => Promise {
+ if (command !== 'gh' && command !== 'glab') {
+ return async () => {}
+ }
+ const scope = selectionScope({})
+ const probeCwd = process.cwd()
+ const previous = localCommandSelections.get(command)
+ const selection: LocalCommandSelection = {
+ scope,
+ selected: previous?.scope === scope ? previous.selected : undefined
+ }
+ localCommandSelections.set(command, selection)
+ return async (binary) => {
+ if (localCommandSelections.get(command) !== selection) {
+ return
+ }
+ if (binary === null || !path.isAbsolute(binary)) {
+ delete selection.selected
+ return
+ }
+ try {
+ const stats = await stat(binary)
+ if (localCommandSelections.get(command) === selection) {
+ const cwd =
+ process.platform === 'win32' &&
+ path.win32.resolve(path.win32.dirname(binary)).toLowerCase() ===
+ path.win32.resolve(probeCwd).toLowerCase()
+ ? probeCwd
+ : undefined
+ // Only a current-directory CLI needs to stay tied to the probe's folder.
+ selection.selected = stats.isFile()
+ ? { binary, stamp: commandFileStamp(stats), cwd }
+ : undefined
+ }
+ } catch {
+ // A binary removed during the probe must not become the runtime selection.
+ if (localCommandSelections.get(command) === selection) {
+ delete selection.selected
+ }
+ }
+ }
+}
+
+/** Native execution reuses preflight's selection without probing or replaying the operation. */
+export function resolveSelectedLocalCommand(
+ command: string,
+ options: ResolveCommandOptions = {}
+): string {
+ const selection = localCommandSelections.get(command)
+ if (!selection?.selected || selection.scope !== selectionScope(options)) {
+ return command
+ }
+ if (
+ selection.selected.cwd &&
+ path.win32.resolve(options.cwd ?? process.cwd()).toLowerCase() !==
+ path.win32.resolve(selection.selected.cwd).toLowerCase()
+ ) {
+ return command
+ }
+ try {
+ const stats = statSync(selection.selected.binary)
+ if (commandFileStamp(stats) === selection.selected.stamp) {
+ return selection.selected.binary
+ }
+ } catch {
+ // Missing or replaced binaries require a fresh version probe.
+ }
+ delete selection.selected
+ return command
+}
+
async function isExecutableFile(candidate: string, isWin: boolean): Promise {
try {
// Why: stat (not lstat) so symlinked CLIs resolve to their real target.
@@ -63,12 +170,15 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise {
- return (await resolveCommandOnLocalPath(command, options)) !== null
+ return (await findLocalCommandPaths(command, options, true)).length > 0
}
/** The absolute path `isCommandOnLocalPath` found, or null. */
@@ -76,8 +186,24 @@ export async function resolveCommandOnLocalPath(
command: string,
options: ResolveCommandOptions = {}
): Promise {
+ return (await findLocalCommandPaths(command, options, true))[0] ?? null
+}
+
+/** Ordered, deduplicated candidates, including executable shims that may fail to run. */
+export async function listLocalCommandPaths(
+ command: string,
+ options: ResolveCommandOptions = {}
+): Promise {
+ return findLocalCommandPaths(command, options, false)
+}
+
+async function findLocalCommandPaths(
+ command: string,
+ options: ResolveCommandOptions,
+ stopAtFirst: boolean
+): Promise {
if (!command) {
- return null
+ return []
}
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
@@ -95,6 +221,8 @@ export async function resolveCommandOnLocalPath(
const searchDirs = hasPathSeparator ? [''] : isWin ? [cwd, ...pathDirs] : pathDirs
const extensions = isWin ? getWindowsExtensions(env, command) : ['']
+ const found: string[] = []
+ const seen = new Set()
for (const dir of searchDirs) {
for (const ext of extensions) {
// Why: forward-slash joins so candidates are statable on every platform
@@ -102,13 +230,18 @@ export async function resolveCommandOnLocalPath(
const candidate = path.posix.join(dir, command) + ext
// Why: preserve the prior `.some(line => path.isAbsolute(line))` filter
// over where/which stdout — only absolute resolutions count.
- if (!isAbsolute(candidate)) {
+ const candidateKey = isWin ? candidate.toLowerCase() : candidate
+ if (!isAbsolute(candidate) || seen.has(candidateKey)) {
continue
}
+ seen.add(candidateKey)
if (await isExecutableFile(candidate, isWin)) {
- return candidate
+ found.push(candidate)
+ if (stopAtFirst || found.length >= (options.maxResults ?? Infinity)) {
+ return found
+ }
}
}
}
- return null
+ return found
}
diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts
index b529dd34b39..e22043a4d69 100644
--- a/src/main/ipc/filesystem-list-files-name-filter.test.ts
+++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts
@@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => {
it('counts only matches against the ripgrep cap', async () => {
wslAwareSpawnMock
- .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
+ .mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep(''))
const files = await listQuickOpenFiles(
@@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => {
it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => {
wslAwareSpawnMock
- .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
+ .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', null, -2))
await expect(
@@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => {
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
wslAwareSpawnMock
- .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
+ .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
@@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => {
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
wslAwareSpawnMock
- .mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
+ .mockImplementationOnce(() => fakeRipgrep('a.ts\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts
index e9a4d406d7f..b6fe3453c22 100644
--- a/src/main/ipc/filesystem-list-files.test.ts
+++ b/src/main/ipc/filesystem-list-files.test.ts
@@ -88,6 +88,33 @@ describe('filesystem-list-files', () => {
)
})
+ it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
+ const child = createMockProcess()
+ spawnMock.mockReturnValue(child)
+ const store: Store = Object.create(null)
+ const promise = listQuickOpenFiles('/repo', store)
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
+ child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
+ if (kind === 'incomplete') {
+ child.emit('close', 0, null)
+ }
+ await expect(promise).rejects.toThrow('not valid UTF-8')
+ if (kind === 'invalid') {
+ expect(child.kill).toHaveBeenCalled()
+ }
+ })
+
+ it('counts NUL-delimited filenames containing newlines as one result each', async () => {
+ const child = createMockProcess()
+ spawnMock.mockReturnValue(child)
+ const store: Store = Object.create(null)
+ const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2)
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
+ child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0')
+ await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r'])
+ expect(child.kill).toHaveBeenCalled()
+ })
+
it('rejects a synchronous launch failure before cleanup has been initialized', async () => {
spawnMock.mockImplementationOnce(() => {
throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })
@@ -122,7 +149,7 @@ describe('filesystem-list-files', () => {
)
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts')
+ p1.stdout?.emit('data', 'one.ts\0two.ts')
p1.emit('close', 0, null)
}, 0)
const result = await promise
@@ -136,38 +163,60 @@ describe('filesystem-list-files', () => {
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version')
})
- it('merges normal files and ignored files and filters correctly', async () => {
- const p1 = createMockProcess()
- const p2 = createMockProcess()
+ it('keeps source files first when only a serialized byte budget is provided', async () => {
+ const source = createMockProcess()
+ const broad = createMockProcess()
+ spawnMock.mockImplementation((_command, args: string[]) =>
+ isIgnoredRgPass(args) ? broad : source
+ )
+ const listing = listQuickOpenFiles(
+ '/mock/root',
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above.
+ {} as Store,
+ undefined,
+ undefined,
+ undefined,
+ 20
+ )
+ await flushMicrotasks()
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
+ source.stdout?.emit('data', 'source.ts\0')
+ source.emit('close', 0, null)
+ await flushMicrotasks()
+ expect(spawnMock).toHaveBeenCalledTimes(2)
+ expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
+ broad.stdout?.emit('data', 'ignored-file.ts\0')
+ await expect(listing).resolves.toEqual(['source.ts'])
+ expect(broad.kill).toHaveBeenCalledOnce()
+ })
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ it('lists normal and ignored files with one broad scan and filters correctly', async () => {
+ const p1 = createMockProcess()
+
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
await flushMicrotasks()
- expect(spawnMock).toHaveBeenCalledTimes(2)
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs')
// Simulate stdout output for normal files
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n')
- p1.emit('close', 0, null)
+ p1.stdout?.emit('data', 'file1.ts\0')
+ p1.stdout?.emit('data', 'node_modules/bad.js\0')
+ p1.stdout?.emit('data', '.git/config\0')
+ p1.stdout?.emit('data', '.github/workflows/ci.yml\0')
+ p1.stdout?.emit('data', 'dir1/') // incomplete line
+ p1.stdout?.emit('data', 'file2.js\0')
- // Simulate stdout output for ignored files
- ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
- ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
- ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate
- ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n')
- p2.emit('close', 0, null)
+ // The broad pass includes ignored files too.
+ p1.stdout?.emit('data', '.env.local\0')
+ p1.stdout?.emit('data', 'dist/generated.js\0')
+ p1.stdout?.emit('data', 'file1.ts\0') // Duplicate
+ p1.stdout?.emit('data', 'node_modules/ignored.js\0')
+ p1.emit('close', 0, null)
}, 10)
const result = await promise
@@ -183,23 +232,16 @@ describe('filesystem-list-files', () => {
it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
+ p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 0, null)
- p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -214,23 +256,16 @@ describe('filesystem-list-files', () => {
it('normalizes absolute WSL rg output for Windows-path worktrees', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
+ p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0')
p1.emit('close', 0, null)
- p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -238,16 +273,13 @@ describe('filesystem-list-files', () => {
it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
Object.defineProperty(p1, 'pid', { value: 1 })
- Object.defineProperty(p2, 'pid', { value: 2 })
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
- spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
+ spawnMock.mockReturnValue(p1)
const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store)
setTimeout(() => {
p1.emit('close', 127, null)
- p2.emit('close', 0, null)
}, 0)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
@@ -256,48 +288,33 @@ describe('filesystem-list-files', () => {
it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
+ spawnMock.mockReturnValue(p1)
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
setTimeout(() => p1.emit('close', 127, null), 0)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
- expect(p2.kill).toHaveBeenCalled()
})
it('rejects rg failures instead of resolving a false-empty list', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
p1.emit('close', 2, null)
- p2.emit('close', 0, null)
}, 10)
await expect(promise).rejects.toThrow('rg exited with code 2')
})
- it('kills the sibling rg pass after one pass fails', async () => {
+ it('does not start another scan after the admitted pass fails', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
@@ -308,27 +325,20 @@ describe('filesystem-list-files', () => {
}, 10)
await expect(promise).rejects.toThrow('rg exited with code 2')
- expect(p2.kill).toHaveBeenCalled()
+ expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('accepts rg code 2 when rg emitted parseable paths first', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
+ p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 2, null)
- p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -339,14 +349,8 @@ describe('filesystem-list-files', () => {
try {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
@@ -355,14 +359,13 @@ describe('filesystem-list-files', () => {
await Promise.resolve()
await Promise.resolve()
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial')
+ p1.stdout?.emit('data', 'src/index.ts\0partial')
const rejection = expect(promise).rejects.toThrow('rg list timed out')
await vi.advanceTimersByTimeAsync(10000)
await rejection
expect(p1.kill).toHaveBeenCalled()
- expect(p2.kill).toHaveBeenCalled()
expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect(p1.listenerCount('error')).toBe(0)
@@ -374,8 +377,7 @@ describe('filesystem-list-files', () => {
it('kills local rg scans when a paired listing is cancelled', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
+ spawnMock.mockReturnValue(p1)
const controller = new AbortController()
const cancellation = new FileListingCancelledError('superseded')
const promise = listQuickOpenFiles(
@@ -390,34 +392,24 @@ describe('filesystem-list-files', () => {
await expect(promise).rejects.toBe(cancellation)
expect(p1.kill).toHaveBeenCalledOnce()
- expect(p2.kill).toHaveBeenCalledOnce()
})
it('filters out .next, .cache, .stably, .vscode, .idea', async () => {
const p1 = createMockProcess()
- const p2 = createMockProcess()
- spawnMock.mockImplementation((_cmd, args: string[]) => {
- if (isIgnoredRgPass(args)) {
- return p2
- }
- return p1
- })
+ spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
- ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
+ p1.stdout?.emit('data', '.next/cache/1.js\0')
+ p1.stdout?.emit('data', '.cache/data.json\0')
+ p1.stdout?.emit('data', '.stably/config.json\0')
+ p1.stdout?.emit('data', '.vscode/settings.json\0')
+ p1.stdout?.emit('data', '.idea/workspace.xml\0')
+ p1.stdout?.emit('data', 'valid.ts\0')
p1.emit('close', 0, null)
-
- // Empty ignored result
- p2.emit('close', 0, null)
}, 10)
const result = await promise
@@ -451,7 +443,7 @@ describe('filesystem-list-files', () => {
})
describe('when the bundled rg cannot start', () => {
- it('kills only the admitted pass when ignored rg fails before spawn', async () => {
+ it('does not kill a process that failed before receiving a pid', async () => {
const primary = createMockProcess()
const missingIgnored = createMockProcess()
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
@@ -461,11 +453,11 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
await flushMicrotasks()
- expect(spawnMock).toHaveBeenCalledTimes(2)
+ expect(spawnMock).toHaveBeenCalledTimes(1)
missingIgnored.emit('close', -2, null)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
- expect(primary.kill).toHaveBeenCalled()
+ expect(primary.kill).not.toHaveBeenCalled()
expect(missingIgnored.kill).not.toHaveBeenCalled()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => missingIgnored.emit('error', error)).not.toThrow()
diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts
index f1e8fb56334..9d9042584d9 100644
--- a/src/main/ipc/filesystem-list-files.ts
+++ b/src/main/ipc/filesystem-list-files.ts
@@ -1,3 +1,5 @@
+import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
+import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { ChildProcess } from 'node:child_process'
import type { Store } from '../persistence'
@@ -8,7 +10,6 @@ import {
buildExcludePathPrefixes,
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
- type RgOutputMode,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
@@ -79,6 +80,10 @@ export async function listQuickOpenFiles(
const runRg = (args: string[]): Promise => {
return new Promise((resolve, reject) => {
+ const filenameDecoder = new RipgrepFilenameDecoder((error) => {
+ killSpawnedRipgrepProcess(child)
+ finish(error)
+ }, Boolean(wslDistroForOutput))
let buf = ''
let done = false
let parseablePathCount = 0
@@ -138,18 +143,22 @@ export async function listQuickOpenFiles(
return
}
let timer: ReturnType
- const handleStdoutData = (chunk: string): void => {
- buf += chunk
+ const handleStdoutData = (chunk: Buffer | string): void => {
+ const decoded = filenameDecoder.decode(chunk)
+ if (decoded === null) {
+ return
+ }
+ buf += decoded
let start = 0
- let newlineIdx = buf.indexOf('\n', start)
- while (newlineIdx !== -1) {
- if (processLine(buf.substring(start, newlineIdx))) {
+ let delimiterIdx = buf.indexOf('\0', start)
+ while (delimiterIdx !== -1) {
+ if (processLine(buf.substring(start, delimiterIdx))) {
buf = ''
finishAtLimit()
return
}
- start = newlineIdx + 1
- newlineIdx = buf.indexOf('\n', start)
+ start = delimiterIdx + 1
+ delimiterIdx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
@@ -211,14 +220,15 @@ export async function listQuickOpenFiles(
finish(new Error(`rg killed by ${signal}`))
return
}
+ if (!filenameDecoder.finish()) {
+ return
+ }
if (buf && processLine(buf)) {
buf = ''
finishAtLimit()
return
}
- if (code === 0 || code === 1) {
- finish()
- } else if (code === 2 && parseablePathCount > 0) {
+ if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
// rg can return 2 for unreadable subdirectories while still listing
// usable files from the rest of the root.
finish()
@@ -257,7 +267,6 @@ export async function listQuickOpenFiles(
children.push({ child, isDone: () => done, finish })
- child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -277,9 +286,7 @@ export async function listQuickOpenFiles(
}
const killSurvivors = (): void => {
- // Why: if one rg pass fails, Promise.all rejects immediately while the
- // sibling scan can keep walking a huge tree until timeout. Stop it so
- // repeated Quick Open attempts do not accumulate local rg processes.
+ // Failed listings must release any process still walking the tree.
for (const entry of children) {
if (entry.isDone()) {
continue
@@ -292,34 +299,29 @@ export async function listQuickOpenFiles(
}
function finishAtLimit(): void {
- for (const entry of children) {
- if (entry.isDone()) {
- continue
- }
- entry.finish()
- if (entry.child.exitCode === null && entry.child.signalCode === null) {
- killSpawnedRipgrepProcess(entry.child)
- }
- }
+ killSurvivors()
}
+
try {
- const primaryRun = runRg(primary)
if (maxResults === undefined && maxSerializedBytes === undefined) {
- // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn.
- await (children[0]?.child.pid === undefined
- ? primaryRun
- : Promise.all([primaryRun, runRg(ignoredPass)]))
+ // The broader pass already includes source files; an unbounded listing needs only one scan.
+ await runRg(ignoredPass)
} else {
// Why: ignored-file output can be much larger and faster than the primary pass; let source
// files claim every bounded autocomplete budget first, including the transport byte cap.
- await primaryRun
+ await runRg(primary)
if (
(maxResults === undefined || files.size < maxResults) &&
(maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes)
) {
// Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches.
await runRg(ignoredPass).catch((err: unknown) => {
- if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) {
+ if (
+ !pathFilter ||
+ signal?.aborted ||
+ err instanceof RipgrepUnavailableError ||
+ err instanceof RipgrepFilenameError
+ ) {
throw err
}
})
@@ -334,19 +336,3 @@ export async function listQuickOpenFiles(
? result
: limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes)
}
-
-function getQuickOpenRgOutputMode(
- rawLine: string,
- translatedLine: string,
- rootPath: string
-): RgOutputMode {
- if (
- translatedLine !== rawLine ||
- rawLine.startsWith('/') ||
- /^[A-Za-z]:[\\/]/.test(rawLine) ||
- rawLine.startsWith('\\\\')
- ) {
- return { kind: 'absolute', rootPath }
- }
- return { kind: 'cwd-relative' }
-}
diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts
index e9415d66431..57c817c3b40 100644
--- a/src/main/ipc/filesystem-search-file-paths.test.ts
+++ b/src/main/ipc/filesystem-search-file-paths.test.ts
@@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => {
)
})
+ it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => {
+ const child = createMockProcess()
+ wslAwareSpawnMock.mockReturnValue(child)
+ const controller = new AbortController()
+ const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
+ query: 'file',
+ limit: 2,
+ signal: controller.signal
+ })
+ await flushMicrotasks()
+ child.stdout?.emit('data', Buffer.from([0xf0, 0x9f]))
+ controller.abort()
+ await expect(promise).rejects.toSatisfy(isFileListingCancellation)
+ })
+
+ it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
+ const child = createMockProcess()
+ wslAwareSpawnMock.mockReturnValue(child)
+ const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 })
+ await flushMicrotasks()
+ child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
+ if (kind === 'incomplete') {
+ child.emit('close', 0, null)
+ }
+ await expect(promise).rejects.toThrow('not valid UTF-8')
+ if (kind === 'invalid') {
+ expect(child.kill).toHaveBeenCalled()
+ }
+ })
+
+ it('preserves control characters within ranked paths', async () => {
+ const child = createMockProcess()
+ wslAwareSpawnMock.mockReturnValue(child)
+ const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
+ query: 'target',
+ limit: 2
+ })
+ await flushMicrotasks()
+ child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0')
+ child.emit('close', 0, null)
+ expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort())
+ })
+
+ it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => {
+ const child = createMockProcess()
+ wslAwareSpawnMock.mockReturnValue(child)
+ const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
+ query: 'target',
+ limit: 2
+ })
+ await flushMicrotasks()
+ child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1))
+ await expect(promise).rejects.toThrow('file path exceeds the listing limit')
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ child.stdout?.emit('data', 'target.ts\0')
+ child.emit('close', 0, null)
+ })
+
it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
@@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => {
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1)
expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg')
expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
- ;(child.stdout as unknown as EventEmitter).emit(
+ child.stdout?.emit(
'data',
- `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n`
- )
- ;(child.stdout as unknown as EventEmitter).emit(
- 'data',
- 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n'
+ `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0`
)
+ child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
- ;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
+ child.stdout?.emit('data', 'src/target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
@@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => {
await flushMicrotasks()
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2)
- ;(succeeded.stdout as unknown as EventEmitter).emit(
- 'data',
- 'data/chunk-077568/sta-4354-gitignored-target.bin\n'
- )
+ succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
- ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
+ succeeded.stdout?.emit('data', 'src/target.ts\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts
index 4a003aa17c5..3de6f4c6342 100644
--- a/src/main/ipc/filesystem-search-file-paths.ts
+++ b/src/main/ipc/filesystem-search-file-paths.ts
@@ -1,3 +1,5 @@
+import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
+import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { Store } from '../persistence'
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
@@ -6,8 +8,7 @@ import {
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
shouldExcludeQuickOpenRelPath,
- shouldIncludeQuickOpenPath,
- type RgOutputMode
+ shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search'
import {
@@ -110,7 +111,11 @@ function scanRipgrepPaths(args: {
return Promise.reject(fileListingCancellationError(args.signal))
}
return new Promise((resolve, reject) => {
- const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a)
+ const filenameDecoder = new RipgrepFilenameDecoder((error) => {
+ killSpawnedRipgrepProcess(child)
+ finish(error)
+ }, Boolean(args.wslDistroForOutput))
+ const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0)
let done = false
let parseablePathCount = 0
let processErrorObserved = false
@@ -141,7 +146,7 @@ function scanRipgrepPaths(args: {
: rawLine
const relPath = normalizeQuickOpenRgLine(
translated,
- getOutputMode(rawLine, translated, args.authorizedRootPath)
+ getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath)
)
if (relPath === null) {
return
@@ -178,11 +183,19 @@ function scanRipgrepPaths(args: {
resolve()
}
}
- const handleStdoutData = (chunk: string): void => {
- pathAccumulator.push(chunk, (path) => {
+ const handleStdoutData = (chunk: Buffer | string): void => {
+ const decoded = filenameDecoder.decode(chunk)
+ if (decoded === null) {
+ return
+ }
+ const result = pathAccumulator.push(decoded, (path) => {
processLine(path)
return true
})
+ if (result === 'path-too-large') {
+ killSpawnedRipgrepProcess(child)
+ finish(new Error('Quick Open file path exceeds the listing limit'))
+ }
}
const handleStderrData = (): void => {
/* drain */
@@ -233,6 +246,9 @@ function scanRipgrepPaths(args: {
finish(new Error(`rg killed by ${signal}`))
return
}
+ if (!filenameDecoder.finish()) {
+ return
+ }
const trailingPath = pathAccumulator.finish()
if (trailingPath) {
processLine(trailingPath)
@@ -249,7 +265,6 @@ function scanRipgrepPaths(args: {
finish(fileListingCancellationError(args.signal))
}
- child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -265,12 +280,3 @@ function scanRipgrepPaths(args: {
}
})
}
-
-function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode {
- return translatedLine !== rawLine ||
- rawLine.startsWith('/') ||
- /^[A-Za-z]:[\\/]/.test(rawLine) ||
- rawLine.startsWith('\\\\')
- ? { kind: 'absolute', rootPath }
- : { kind: 'cwd-relative' }
-}
diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts
index 9c37bacb20a..d4096500381 100644
--- a/src/main/ipc/filesystem-search-rg-timeout.test.ts
+++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts
@@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => {
}
)
- it('keeps post-spawn errors on the existing empty-result path', async () => {
+ it('rejects post-spawn errors instead of returning an empty result', async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
@@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => {
await flushMicrotasks()
child.emit('error', new Error('post-spawn failure'))
- await expect(promise).resolves.toMatchObject({ files: [] })
+ await expect(promise).rejects.toThrow('post-spawn failure')
})
// Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler
@@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => {
expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg')
})
+ it('marks WSL filenames that UNC cannot represent as incomplete', async () => {
+ const child = createMockProcess()
+ wslAwareSpawnMock.mockReturnValue(child)
+ getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler.
+ registerFilesystemHandlers({} as never)
+ const promise = handlers.get('fs:search')!(
+ { sender: { id: 7 } },
+ { rootPath: 'C:\\repo', query: 'hello' }
+ )
+ await flushMicrotasks()
+ child.stdout?.emit(
+ 'data',
+ `${JSON.stringify({
+ type: 'match',
+ data: {
+ path: { text: './a\\b.txt' },
+ lines: { text: 'hello\n' },
+ line_number: 1,
+ submatches: [{ start: 0, end: 5 }]
+ }
+ })}\n`
+ )
+ child.emit('close', 0, null)
+ await expect(promise).resolves.toMatchObject({ files: [], truncated: true })
+ expect(toWindowsWslPathMock).not.toHaveBeenCalled()
+ })
+
it('translates WSL rg output for Windows-path project search results', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts
index 9212200e151..94cc07a2f1f 100644
--- a/src/main/ipc/filesystem/filesystem-search-handlers.ts
+++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts
@@ -1,3 +1,4 @@
+import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics'
import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines'
import { ipcMain } from 'electron'
import type { ChildProcess } from 'node:child_process'
@@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
return new Promise((resolvePromise, rejectPromise) => {
- const rgArgs = buildRgArgs(args.query, rootPath, args)
+ const rgArgs = buildRgArgs(args.query, '.', args)
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
const previousChild = activeTextSearches.get(searchKey)
if (previousChild) {
@@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
const acc = createAccumulator()
- const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
+ const lines = new SearchSubprocessLineAccumulator()
+ const diagnostics = new RipgrepSearchDiagnostics()
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
@@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
let killTimeout: ReturnType
const transformAbsPath = wslDistroForOutput
- ? (path: string): string =>
- path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path
+ ? (path: string): string | null =>
+ path.includes('\\')
+ ? null
+ : path.startsWith('/')
+ ? toWindowsWslPath(path, wslDistroForOutput)
+ : path
: undefined
const finish = (result: SearchResult | PromiseLike): void => {
@@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
resolvePromise(result)
}
- const resolveOnce = (): void => finish(finalize(acc))
+ const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => {
+ const error = diagnostics.failure(code, signal, acc)
+ finish(error ? Promise.reject(error) : finalize(acc))
+ }
const rejectUnavailable = (): void =>
finish(Promise.reject(bundledRipgrepUnavailableError()))
const processLine = (line: string): void => {
@@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
activeTextSearches.set(searchKey, nextChild)
const handleStdoutData = (chunk: string): void => {
- lines.push(chunk, processLine)
+ if (!lines.push(chunk, processLine)) {
+ acc.truncated = true
+ if (child) {
+ killSpawnedRipgrepProcess(child)
+ }
+ resolveOnce()
+ }
}
- const handleStderrData = (): void => {
- // Drain stderr so rg cannot block on a full pipe.
+ const handleStderrData = (chunk: Buffer): void => {
+ diagnostics.append(chunk)
}
const handleError = (error: NodeJS.ErrnoException): void => {
processErrorObserved = true
@@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
return
}
if (child && isRipgrepUnavailableExit(child, null, null)) {
- // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the
- // binary for it tells the user to reinstall Orca over a workspace that simply moved.
- // Why detach close first: a failed spawn emits error THEN close(code < 0), and
- // close settles synchronously, so this probe would otherwise race it on a sub-ms
- // margin -- two measurements disagreed on which wins. Detaching makes it deterministic.
+ // Distinguish a missing workspace from a missing binary before close can settle.
child.off('close', handleClose)
- // Why catch: a failed probe must not strand the search; fall back to the prior verdict.
void isRipgrepSpawnCwdUsable(rootPath)
.catch(() => true)
.then((usable) => {
- // Why re-check: finish() drops its argument once settled, so a rejected promise
- // built after the close handler already won would go unhandled.
+ // A late rejected promise must not escape after close settles the search.
if (resolved) {
return
}
@@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
})
return
}
- resolveOnce()
+ finish(Promise.reject(error))
+ if (child) {
+ killSpawnedRipgrepProcess(child)
+ }
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
// Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise
@@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
rejectUnavailable()
return
}
- const tail = lines.finish()
+ const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null
if (tail !== null) {
processLine(tail)
}
- resolveOnce()
+ resolveOnce(code ?? -1, signal)
}
nextChild.stdout?.setEncoding('utf-8')
diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts
index 992f0c6e11f..8936e2eb15a 100644
--- a/src/main/ipc/github-issue-mutation-handlers.ts
+++ b/src/main/ipc/github-issue-mutation-handlers.ts
@@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve
export function registerGitHubIssueMutationHandlers(store: Store): void {
ipcMain.handle(
'gh:updateIssue',
- async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
+ async (
+ event,
+ args: GitHubRepoScopedArgs & {
+ number: number
+ updates: GitHubIssueUpdate
+ ownerRepo?: GitHubOwnerRepo
+ }
+ ) => {
const repo = assertRegisteredGitHubRepo(args, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
@@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.number,
args.updates,
getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
+ getGitHubLocalGitOptionArgs(store, repo)[0],
+ repo.issueSourcePreference,
+ ...(args.ownerRepo ? [args.ownerRepo] : [])
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.body.trim(),
getGitHubRepoConnectionId(repo),
args.prRepo ?? null,
- ...getGitHubLocalGitOptionArgs(store, repo)
+ getGitHubLocalGitOptionArgs(store, repo)[0],
+ // Why: the issue source selector only scopes issues; PR comments keep their resolution.
+ args.type === 'pr' ? undefined : repo.issueSourcePreference
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
}
)
- ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return listLabels(
- repo.path,
- repo.issueSourcePreference,
- getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
- )
- })
+ ipcMain.handle(
+ 'gh:listLabels',
+ (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
+ const connectionId = getGitHubRepoConnectionId(repo)
+ if (args.ownerRepo) {
+ return listLabels(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ localGitOptions[0],
+ args.ownerRepo
+ )
+ }
+ return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions)
+ }
+ )
- ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return listAssignableUsers(
- repo.path,
- repo.issueSourcePreference,
- getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
- )
- })
+ ipcMain.handle(
+ 'gh:listAssignableUsers',
+ (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
+ const connectionId = getGitHubRepoConnectionId(repo)
+ if (args.ownerRepo) {
+ return listAssignableUsers(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ localGitOptions[0],
+ args.ownerRepo
+ )
+ }
+ return listAssignableUsers(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ ...localGitOptions
+ )
+ }
+ )
}
diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts
index ffef0da244b..eb51af7773e 100644
--- a/src/main/ipc/github-issue-source-preference.test.ts
+++ b/src/main/ipc/github-issue-source-preference.test.ts
@@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => {
'origin'
)
})
+
+ it('passes the displayed repository ahead of the current source preference', async () => {
+ harness.repos[0].issueSourcePreference = 'upstream'
+ const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+ mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
+ getWorkItemDetailsMock.mockResolvedValue(null)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
+ registerGitHubHandlers(store as never, stats as never)
+
+ await handlers['gh:workItemDetails'](null, {
+ repoPath: '/workspace/repo',
+ number: 12,
+ type: 'issue',
+ ownerRepo
+ })
+ await handlers['gh:updateIssue'](null, {
+ repoPath: '/workspace/repo',
+ number: 12,
+ updates: { body: 'Origin edit' },
+ ownerRepo
+ })
+ await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo })
+ await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo })
+
+ expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 12,
+ 'issue',
+ null,
+ undefined,
+ 'upstream',
+ ownerRepo
+ )
+ expect(mocks.client.updateIssue).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 12,
+ { body: 'Origin edit' },
+ null,
+ undefined,
+ 'upstream',
+ ownerRepo
+ )
+ expect(mocks.client.listLabels).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'upstream',
+ null,
+ undefined,
+ ownerRepo
+ )
+ expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'upstream',
+ null,
+ undefined,
+ ownerRepo
+ )
+ })
+
+ it('pins the repo origin source preference on issue update and comment IPC', async () => {
+ harness.repos = [
+ {
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ issueSourcePreference: 'origin'
+ }
+ ]
+ mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
+ mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' })
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
+ registerGitHubHandlers(store as never, stats as never)
+
+ await handlers['gh:updateIssue'](null, {
+ repoPath: '/workspace/repo',
+ number: 5,
+ updates: { body: 'Updated' }
+ })
+ await handlers['gh:addIssueComment'](null, {
+ repoPath: '/workspace/repo',
+ number: 5,
+ body: 'Issue comment'
+ })
+ await handlers['gh:addIssueComment'](null, {
+ repoPath: '/workspace/repo',
+ number: 6,
+ body: 'PR comment',
+ type: 'pr'
+ })
+
+ expect(mocks.client.updateIssue).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 5,
+ { body: 'Updated' },
+ null,
+ undefined,
+ 'origin'
+ )
+ expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
+ 1,
+ '/workspace/repo',
+ 5,
+ 'Issue comment',
+ null,
+ null,
+ undefined,
+ 'origin'
+ )
+ expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
+ 2,
+ '/workspace/repo',
+ 6,
+ 'PR comment',
+ null,
+ null,
+ undefined,
+ undefined
+ )
+ })
})
diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts
index 26c21b3db81..94e982d60dc 100644
--- a/src/main/ipc/github-work-item-handlers.ts
+++ b/src/main/ipc/github-work-item-handlers.ts
@@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void {
}
)
- ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return dispatchWorkItem(
- args,
- repo,
- getWorkItemDetails,
- getGitHubLocalGitOptionArgs(store, repo)[0]
- )
- })
+ ipcMain.handle(
+ 'gh:workItemDetails',
+ (_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ return dispatchWorkItem(
+ args,
+ repo,
+ args.ownerRepo
+ ? (...params) => getWorkItemDetails(...params, args.ownerRepo)
+ : getWorkItemDetails,
+ getGitHubLocalGitOptionArgs(store, repo)[0]
+ )
+ }
+ )
ipcMain.handle(
'gh:notifyWorkItemMutated',
diff --git a/src/main/ipc/github-wsl-runtime-routing.test.ts b/src/main/ipc/github-wsl-runtime-routing.test.ts
index e2f30c18eba..977422cb68e 100644
--- a/src/main/ipc/github-wsl-runtime-routing.test.ts
+++ b/src/main/ipc/github-wsl-runtime-routing.test.ts
@@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => {
)
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
+ const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+ await handlers['gh:listLabels'](null, {
+ repoPath: '/workspace/repo',
+ ownerRepo: metadataRepo
+ })
+ await handlers['gh:listAssignableUsers'](null, {
+ repoPath: '/workspace/repo',
+ ownerRepo: metadataRepo
+ })
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => {
7,
{ body: 'Updated' },
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(addIssueCommentMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => {
'Comment',
null,
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
+ expect(listLabelsMock).toHaveBeenLastCalledWith(
+ '/workspace/repo',
+ undefined,
+ null,
+ localGitOptions,
+ metadataRepo
+ )
+ expect(listAssignableUsersMock).toHaveBeenLastCalledWith(
+ '/workspace/repo',
+ undefined,
+ null,
+ localGitOptions,
+ metadataRepo
+ )
expect(listAssignableUsersMock).toHaveBeenCalledWith(
'/workspace/repo',
undefined,
diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/main/ipc/markdown-documents-remote-paths.test.ts
new file mode 100644
index 00000000000..0d5dc66983a
--- /dev/null
+++ b/src/main/ipc/markdown-documents-remote-paths.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it, vi } from 'vitest'
+import type * as NodePath from 'node:path'
+
+vi.mock('node:path', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, extname: actual.win32.extname }
+})
+
+import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents'
+
+describe('remote Markdown filenames on a Windows client', () => {
+ it('keeps the local filename helper on Windows semantics', () => {
+ expect(isMarkdownDocumentName('notes\\.md')).toBe(false)
+ expect(isMarkdownDocumentName('notes.md\\')).toBe(true)
+ })
+
+ it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])(
+ 'preserves POSIX filename %s and its extension',
+ (relativePath) => {
+ const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1)
+ expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({
+ filePath: `/home/repo/${relativePath}`,
+ relativePath,
+ basename,
+ name: basename.slice(0, basename.lastIndexOf('.'))
+ })
+ }
+ )
+
+ it.each(['notes.md\\', 'nested/.md', '../outside.md'])(
+ 'rejects non-Markdown or escaping POSIX filename %s',
+ (relativePath) => {
+ expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull()
+ }
+ )
+
+ it('normalizes Windows remote separators before reading the basename', () => {
+ expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({
+ filePath: 'C:\\repo/notes/README.MD',
+ relativePath: 'notes/README.MD',
+ basename: 'README.MD',
+ name: 'README'
+ })
+ expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull()
+ expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull()
+ })
+})
diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts
index daee482bae1..13f48b78e17 100644
--- a/src/main/ipc/markdown-documents-ripgrep.test.ts
+++ b/src/main/ipc/markdown-documents-ripgrep.test.ts
@@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => {
expect(child.listenerCount('close')).toBe(0)
})
+ it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => {
+ vi.useFakeTimers()
+ const result = listMarkdownDocuments(root)
+ const outcome = expect(result).rejects.toThrow('timed out')
+ child.stdout.write(Buffer.from([0xf0, 0x9f]))
+ await vi.advanceTimersByTimeAsync(15_000)
+ await outcome
+ })
+
+ it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
+ const result = listMarkdownDocuments(root)
+ child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
+ if (kind === 'incomplete') {
+ child.emit('close', 0, null)
+ }
+ await expect(result).rejects.toThrow('not valid UTF-8')
+ })
+
it('accepts an empty listing', async () => {
const result = listMarkdownDocuments(root)
child.emit('close', 1, null)
diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts
index b23f5748cb4..698935b1f62 100644
--- a/src/main/ipc/markdown-documents.test.ts
+++ b/src/main/ipc/markdown-documents.test.ts
@@ -1,7 +1,41 @@
import { describe, expect, it } from 'vitest'
-import { markdownDocumentFromFilePath } from './markdown-documents'
+import {
+ markdownDocumentFromFilePath,
+ markdownDocumentFromRelativePath
+} from './markdown-documents'
describe('markdownDocumentFromFilePath', () => {
+ it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => {
+ expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({
+ filePath: '/repo\\/a\\b.md',
+ relativePath: 'a\\b.md',
+ basename: 'a\\b.md'
+ })
+ })
+
+ it('preserves POSIX remote filenames and trailing root backslashes', () => {
+ for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) {
+ expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({
+ filePath: `/repo\\/${name}`,
+ relativePath: name,
+ basename: name.slice(name.lastIndexOf('/') + 1)
+ })
+ }
+ expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull()
+ })
+
+ it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])(
+ 'preserves Windows separator handling under %s',
+ (root) => {
+ expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({
+ filePath: `${root.slice(0, -1)}/a/b.md`,
+ relativePath: 'a/b.md',
+ basename: 'b.md'
+ })
+ expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull()
+ }
+ )
+
it('keeps in-root path segments that merely start with parent traversal text', () => {
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
filePath: '/workspace/..notes/file.md',
diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts
index 6695c7aa89c..1f457dba97e 100644
--- a/src/main/ipc/markdown-documents.ts
+++ b/src/main/ipc/markdown-documents.ts
@@ -1,4 +1,15 @@
-import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
+import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
+import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
+import { normalizeRelativePath } from '../../shared/text-search-paths'
+import {
+ basename as pathBasename,
+ extname,
+ isAbsolute,
+ join,
+ posix,
+ relative,
+ resolve
+} from 'node:path'
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
import { parseWslPath } from '../wsl'
@@ -7,36 +18,34 @@ import {
ripgrepMissingCwdError
} from '../../shared/ripgrep-process-availability'
-function normalizeRelativePath(path: string): string {
- return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
+export function isMarkdownDocumentName(name: string): boolean {
+ return isMarkdownExtension(extname(name))
}
-export function isMarkdownDocumentName(name: string): boolean {
- const extension = extname(name).toLowerCase()
- return extension === '.md' || extension === '.mdx' || extension === '.markdown'
+function isMarkdownExtension(extension: string): boolean {
+ const normalized = extension.toLowerCase()
+ return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
- const normalizedPath = relativePath.replaceAll('\\', '/')
- return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
+ return relativePath.slice(relativePath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
-function hasParentTraversalSegment(relativePath: string): boolean {
- return relativePath.split(/[\\/]+/).includes('..')
-}
-
function rootRelativePath(rootPath: string, filePath: string): string | null {
const resolvedRoot = resolve(rootPath)
const resolvedFile = resolve(filePath)
const relativePath = relative(resolvedRoot, resolvedFile)
- if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
+ if (
+ !isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) ||
+ isAbsolute(relativePath)
+ ) {
return null
}
- return normalizeRelativePath(relativePath)
+ return normalizeRelativePath(relativePath, rootPath)
}
export function fileDocumentFromFilePath(
@@ -50,7 +59,7 @@ export function fileDocumentFromFilePath(
rootRelativePath(rootPath, filePath) ??
(options.outsideRootRelativePath === 'basename'
? basename
- : normalizeRelativePath(relative(rootPath, filePath)))
+ : normalizeRelativePath(relative(rootPath, filePath), rootPath))
return {
filePath,
relativePath,
@@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
- const normalizedRelativePath = normalizeRelativePath(relativePath)
+ const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
- if (!isMarkdownDocumentName(basename)) {
+ // Remote separators are already normalized; a POSIX backslash stays part of the name.
+ const extension = posix.extname(basename)
+ if (!isMarkdownExtension(extension)) {
return null
}
- const extension = extname(basename)
- const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
+ const normalizedRoot = rootPath.replace(
+ isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/,
+ ''
+ )
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
@@ -111,9 +124,9 @@ export async function listMarkdownDocuments(
'--null',
'--path-separator',
'/',
- // Directory-only globs preserve hidden Markdown files without traversing hidden folders.
+ // Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders.
'--glob',
- '**',
+ '*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}',
'--glob',
'!**/.*/',
'--glob',
@@ -131,6 +144,10 @@ export async function listMarkdownDocuments(
)
return new Promise((resolveListing, reject) => {
+ const filenameDecoder = new RipgrepFilenameDecoder(
+ (error) => finish(error),
+ Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro)
+ )
const documents: MarkdownDocument[] = []
let carry = ''
let stderr = ''
@@ -172,8 +189,12 @@ export async function listMarkdownDocuments(
const onStderr = (chunk: string): void => {
stderr = (stderr + chunk).slice(0, 4096)
}
- const onData = (chunk: string): void => {
- carry += chunk
+ const onData = (chunk: Buffer | string): void => {
+ const decoded = filenameDecoder.decode(chunk)
+ if (decoded === null) {
+ return
+ }
+ carry += decoded
let start = 0
let end: number
while ((end = carry.indexOf('\0', start)) !== -1) {
@@ -201,10 +222,11 @@ export async function listMarkdownDocuments(
finish(ripgrepMissingCwdError(rootPath))
} else if (signal || (code !== 0 && code !== 1)) {
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
- } else if (carry) {
- finish(new Error('Incomplete path in Markdown document listing'))
} else {
- finish()
+ if (!filenameDecoder.finish()) {
+ return
+ }
+ finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined)
}
}
const timer = setTimeout(
@@ -212,7 +234,6 @@ export async function listMarkdownDocuments(
MARKDOWN_LISTING_TIMEOUT_MS
)
timer.unref?.()
- child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', onData)
child.stderr?.on('data', onStderr)
diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts
index 37a061e0541..ec13423fcb6 100644
--- a/src/main/ipc/preflight-agent-detection.test.ts
+++ b/src/main/ipc/preflight-agent-detection.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
+ listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
-vi.mock('./command-path-resolver', () => ({
- isCommandOnLocalPath: isCommandOnLocalPathMock
+vi.mock('./command-path-resolver', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isCommandOnLocalPath: isCommandOnLocalPathMock,
+ listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -113,6 +118,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts
index cc64e2beeca..d4159c3f7b2 100644
--- a/src/main/ipc/preflight-agent-refresh.test.ts
+++ b/src/main/ipc/preflight-agent-refresh.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
+ listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
-vi.mock('./command-path-resolver', () => ({
- isCommandOnLocalPath: isCommandOnLocalPathMock
+vi.mock('./command-path-resolver', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isCommandOnLocalPath: isCommandOnLocalPathMock,
+ listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -109,6 +114,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts
index 63c2b35aceb..5e571b18613 100644
--- a/src/main/ipc/preflight-command-exec.test.ts
+++ b/src/main/ipc/preflight-command-exec.test.ts
@@ -1,15 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import path from 'node:path'
+import type * as LocalCommandResolver from './command-path-resolver'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
-const { runPreflightCommandInWslMock } = vi.hoisted(() => ({
- runPreflightCommandInWslMock: vi.fn()
+const {
+ runPreflightCommandInWslMock,
+ execFileAsyncMock,
+ listLocalCommandPathsMock,
+ runProcessMock
+} = vi.hoisted(() => ({
+ runPreflightCommandInWslMock: vi.fn(),
+ execFileAsyncMock: vi.fn(),
+ listLocalCommandPathsMock: vi.fn(),
+ runProcessMock: vi.fn()
}))
+vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
+
+vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined }))
+
vi.mock('./preflight-wsl-command', () => ({
runPreflightCommandInWsl: runPreflightCommandInWslMock
}))
-import { isCommandOnPath } from './preflight-command-exec'
+vi.mock('./command-path-resolver', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isCommandOnLocalPath: vi.fn(async () => false),
+ listLocalCommandPaths: listLocalCommandPathsMock
+}))
+
+// Why: `findRunnableLocalCommand` decides what to spawn next from the error
+// `execFile` puts on a failed probe, so the shapes below have to be its own.
+vi.mock('child_process', () => {
+ const execFileWithPromisify = Object.assign(vi.fn(), {
+ [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
+ })
+ return { execFile: execFileWithPromisify, spawn: vi.fn() }
+})
+
+import { findRunnableLocalCommand, isCommandOnPath } from './preflight-command-exec'
describe('isCommandOnPath', () => {
const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__'
@@ -59,3 +88,222 @@ describe('isCommandOnPath', () => {
await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected)
})
})
+
+describe('findRunnableLocalCommand', () => {
+ const shim = '/Users/tester/.asdf/shims/gh'
+ const second = '/Users/tester/.volta/bin/gh'
+ const third = '/usr/local/bin/gh'
+
+ const spawnedCommands = () => execFileAsyncMock.mock.calls.map(([command]) => command)
+
+ let pathBefore = ''
+
+ beforeEach(() => {
+ pathBefore = process.env.PATH ?? ''
+ execFileAsyncMock.mockReset()
+ runProcessMock.mockReset()
+ listLocalCommandPathsMock.mockReset()
+ listLocalCommandPathsMock.mockResolvedValue([shim, second, third])
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ process.env.PATH = pathBefore
+ })
+
+ it('returns the first copy that runs and leaves the rest alone', async () => {
+ execFileAsyncMock.mockImplementation(async (command: string) => {
+ if (command === shim) {
+ throw Object.assign(new Error('cannot execute'), { code: 126 })
+ }
+ return { stdout: 'gh version 2.98.0\n', stderr: '' }
+ })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: second
+ })
+ expect(spawnedCommands()).toEqual([shim, second])
+ })
+
+ it('keeps looking when a copy exits non-zero', async () => {
+ execFileAsyncMock.mockImplementation(async (command: string) => {
+ if (command === third) {
+ return { stdout: 'gh version 2.98.0\n', stderr: '' }
+ }
+ throw Object.assign(new Error('Command failed'), { code: 1 })
+ })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: third
+ })
+ })
+
+ it('stops at a timed-out copy instead of paying the timeout once per copy', async () => {
+ execFileAsyncMock.mockImplementation(async (command: string) => {
+ if (command === second) {
+ throw Object.assign(new Error('Timed out'), { killed: true, code: null })
+ }
+ throw Object.assign(new Error('cannot execute'), { code: 126 })
+ })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'timeout',
+ binary: second
+ })
+ expect(spawnedCommands()).toEqual([shim, second])
+ })
+
+ it('stops on the ETIMEDOUT shape as well as the killed shape', async () => {
+ execFileAsyncMock.mockImplementation((command: string) =>
+ command === second
+ ? Promise.reject(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' }))
+ : Promise.reject(Object.assign(new Error('cannot execute'), { code: 126 }))
+ )
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'timeout',
+ binary: second
+ })
+ expect(spawnedCommands()).toEqual([shim, second])
+ })
+
+ it('reads a rejection that is not an object as an ordinary failure', async () => {
+ execFileAsyncMock.mockImplementation((command: string) =>
+ command === third
+ ? Promise.resolve({ stdout: 'gh version 2.98.0\n', stderr: '' })
+ : Promise.reject('not an error object')
+ )
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: third
+ })
+ expect(spawnedCommands()).toEqual([shim, second, third])
+ })
+
+ it('probes the bare command name when fs finds no candidate', async () => {
+ listLocalCommandPathsMock.mockResolvedValue([])
+ execFileAsyncMock.mockResolvedValue({ stdout: 'gh version 2.98.0\n', stderr: '' })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: 'gh'
+ })
+ expect(spawnedCommands()).toEqual(['gh'])
+ })
+
+ it('probes a relative PATH entry as the absolute directory cwd gives it', async () => {
+ const relativeDir = path.join('.', 'tools')
+ process.env.PATH = [shim.replace('/gh', ''), relativeDir].join(path.delimiter)
+ const absoluteDir = path.resolve(relativeDir)
+ const hidden = `${absoluteDir}/gh`
+ const absolutePath = [shim.replace('/gh', ''), absoluteDir].join(path.delimiter)
+ listLocalCommandPathsMock.mockImplementation(
+ async (_command: string, options?: { env?: NodeJS.ProcessEnv }) =>
+ options?.env?.PATH === absolutePath ? [shim, hidden] : []
+ )
+ execFileAsyncMock.mockImplementation(async (command: string) => {
+ if (command === hidden) {
+ return { stdout: 'gh version 2.98.0\n', stderr: '' }
+ }
+ throw Object.assign(new Error('cannot execute'), { code: 126 })
+ })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: hidden
+ })
+ expect(spawnedCommands()).toEqual([shim, hidden])
+ })
+
+ it('does not pay for the bare name when every PATH entry is absolute', async () => {
+ process.env.PATH = ['/Users/tester/.asdf/shims', '/usr/local/bin'].join(path.delimiter)
+ execFileAsyncMock.mockRejectedValue(Object.assign(new Error('cannot execute'), { code: 126 }))
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'exec_failed',
+ binary: third
+ })
+ expect(spawnedCommands()).not.toContain('gh')
+ })
+
+ it('runs an explicitly selected Windows cmd shim through the shared runner', async () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const command = path.win32.join('C:\\tools', 'gh.cmd')
+ runProcessMock.mockResolvedValue({
+ code: 0,
+ stdout: 'gh version fixture',
+ stderr: '',
+ timedOut: false
+ })
+
+ await expect(findRunnableLocalCommand(command)).resolves.toEqual({
+ status: 'available',
+ binary: command
+ })
+ expect(runProcessMock).toHaveBeenCalledWith({
+ program: command,
+ args: ['--version'],
+ env: undefined,
+ timeoutMs: expect.any(Number)
+ })
+ expect(execFileAsyncMock).not.toHaveBeenCalled()
+ expect(listLocalCommandPathsMock).not.toHaveBeenCalled()
+ })
+
+ it('keeps searching Windows PATH after a broken cmd shim', async () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const command = path.win32.join('C:\\tools', 'gh.cmd')
+ const working = path.win32.join('C:\\working', 'gh.exe')
+ listLocalCommandPathsMock.mockResolvedValue([command, working])
+ runProcessMock.mockResolvedValue({
+ code: 126,
+ stdout: '',
+ stderr: 'broken shim',
+ timedOut: false
+ })
+ execFileAsyncMock.mockResolvedValue({ stdout: 'gh version fixture', stderr: '' })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'available',
+ binary: working
+ })
+ expect(runProcessMock).toHaveBeenCalledOnce()
+ expect(spawnedCommands()).toEqual([working])
+ })
+
+ it('stops on a shared runner timeout', async () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const command = path.win32.join('C:\\tools', 'gh.cmd')
+ listLocalCommandPathsMock.mockResolvedValue([command, third])
+ runProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'timeout',
+ binary: command
+ })
+ expect(execFileAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('shares the five second timeout across failed candidates', async () => {
+ let now = 1000
+ vi.spyOn(Date, 'now').mockImplementation(() => now)
+ execFileAsyncMock.mockImplementation(async (command: string) => {
+ if (command === shim) {
+ now += 4000
+ throw Object.assign(new Error('cannot execute'), { code: 126 })
+ }
+ throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
+ })
+
+ await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
+ status: 'timeout',
+ binary: second
+ })
+ expect(execFileAsyncMock.mock.calls.map(([, , options]) => options.timeout)).toEqual([
+ 5000, 1000
+ ])
+ })
+})
diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts
index 9c67045d512..41bc378fdfe 100644
--- a/src/main/ipc/preflight-command-exec.ts
+++ b/src/main/ipc/preflight-command-exec.ts
@@ -1,8 +1,15 @@
import { execFile } from 'node:child_process'
import path from 'node:path'
+import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
-import { isCommandOnLocalPath } from './command-path-resolver'
+import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs'
+import { runProcess } from '../../shared/child-process/run-process'
+import {
+ beginLocalCommandSelection,
+ isCommandOnLocalPath,
+ listLocalCommandPaths
+} from './command-path-resolver'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
@@ -17,7 +24,11 @@ export function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
-async function withPreflightTimeout(command: string, commandPromise: Promise): Promise {
+async function withPreflightTimeout(
+ command: string,
+ commandPromise: Promise,
+ timeoutMs = PREFLIGHT_COMMAND_TIMEOUT_MS
+): Promise {
let timeout: ReturnType | null = null
try {
return await Promise.race([
@@ -28,7 +39,7 @@ async function withPreflightTimeout(command: string, commandPromise: Promise<
code: 'ETIMEDOUT'
})
reject(error)
- }, PREFLIGHT_COMMAND_TIMEOUT_MS)
+ }, timeoutMs)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
@@ -47,19 +58,36 @@ async function withPreflightTimeout(command: string, commandPromise: Promise<
* docs/reference/wsl-probe-failure-semantics.md before doing so. */
export async function execLocalPreflightCommandOrThrow(
command: string,
- args: string[]
+ args: string[],
+ options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}
): Promise {
- const env = buildLocalPreflightEnv()
+ const env = options.env ?? buildLocalPreflightEnv()
+ const timeoutMs = options.timeoutMs ?? PREFLIGHT_COMMAND_TIMEOUT_MS
+ // Node cannot execFile a batch shim; the shared runner handles its argv safely.
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
+ const result = await withPreflightTimeout(
+ command,
+ runProcess({ program: command, args, env, timeoutMs }),
+ timeoutMs
+ )
+ if (result.timedOut || result.code !== 0) {
+ throw Object.assign(new Error(`Failed running ${command}`), {
+ ...result,
+ code: result.timedOut ? 'ETIMEDOUT' : result.code
+ })
+ }
+ return { stdout: result.stdout, stderr: result.stderr }
+ }
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
- timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
+ timeout: timeoutMs,
// Preflight probes console-subsystem binaries (git, gh, node); without this
// each one flashes a console and steals foreground on Windows (#10488).
windowsHide: true,
...(env ? { env } : {})
- }) as Promise
+ })
- return withPreflightTimeout(command, commandPromise)
+ return withPreflightTimeout(command, commandPromise, timeoutMs)
}
// Throws on any failure — a distro that is booting/unreachable throws the
@@ -76,14 +104,106 @@ export async function execCommandInWslOrThrow(
return withPreflightTimeout('wsl command', commandPromise)
}
+const PREFLIGHT_LOCAL_PROBE_LIMIT = 4
+
+export type LocalCommandProbe =
+ | { status: 'available'; binary: string }
+ | { status: 'absent' }
+ | { status: 'exec_failed' | 'timeout' | 'limit_reached'; binary: string }
+
+function probeTimedOut(error: unknown): boolean {
+ if (typeof error !== 'object' || error === null) {
+ return false
+ }
+ return (
+ ('killed' in error && error.killed === true) || ('code' in error && error.code === 'ETIMEDOUT')
+ )
+}
+
+async function localProbeCandidates(
+ command: string,
+ env: NodeJS.ProcessEnv | undefined
+): Promise {
+ const isWin = process.platform === 'win32'
+ const probeEnv = env ?? process.env
+ // Keep relative PATH entries in their original position, as execFile does.
+ const absoluteEnv = isWin
+ ? probeEnv
+ : {
+ ...probeEnv,
+ PATH: (probeEnv.PATH ?? '')
+ .split(path.delimiter)
+ .map((dir) => path.resolve(dir))
+ .join(path.delimiter)
+ }
+ const maxResults = PREFLIGHT_LOCAL_PROBE_LIMIT + 1
+ const paths = await listLocalCommandPaths(command, { env: absoluteEnv, maxResults })
+ const installPaths =
+ isWin || paths.length >= maxResults
+ ? []
+ : await listLocalCommandPaths(command, {
+ env: {
+ PATH: getSystemCliInstallDirectories(process.platform, homedir()).join(path.delimiter)
+ },
+ maxResults
+ })
+ return [...new Set([...paths, ...installPaths])]
+}
+
+/** Try only version probes; authentication must stay on the selected binary. */
+export async function findRunnableLocalCommand(command: string): Promise {
+ const publishSelection = beginLocalCommandSelection(command)
+ const result = await probeRunnableLocalCommand(command)
+ await publishSelection(result.status === 'available' ? result.binary : null)
+ return result
+}
+
+async function probeRunnableLocalCommand(command: string): Promise {
+ const env = buildLocalPreflightEnv()
+ const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\'))
+ // An explicit path is the user's selection, even when it cannot run.
+ const candidates = explicit ? [] : await localProbeCandidates(command, env)
+ const probes = candidates.length ? candidates.slice(0, PREFLIGHT_LOCAL_PROBE_LIMIT) : [command]
+ const deadline = Date.now() + PREFLIGHT_COMMAND_TIMEOUT_MS
+ for (const binary of probes) {
+ const timeoutMs = deadline - Date.now()
+ if (timeoutMs <= 0) {
+ return { status: 'timeout', binary }
+ }
+ try {
+ await execLocalPreflightCommandOrThrow(binary, ['--version'], { env, timeoutMs })
+ return { status: 'available', binary }
+ } catch (error) {
+ if (probeTimedOut(error)) {
+ return { status: 'timeout', binary }
+ }
+ if (
+ candidates.length === 0 &&
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ error.code === 'ENOENT' &&
+ !(await isCommandOnLocalPath(explicit ? path.resolve(command) : command, { env }))
+ ) {
+ return { status: 'absent' }
+ }
+ }
+ }
+ return {
+ status: candidates.length > PREFLIGHT_LOCAL_PROBE_LIMIT ? 'limit_reached' : 'exec_failed',
+ binary: probes.at(-1) ?? command
+ }
+}
+
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise {
+ if (!wslTarget) {
+ return (await findRunnableLocalCommand(command)).status === 'available'
+ }
try {
- await (wslTarget
- ? execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
- : execLocalPreflightCommandOrThrow(command, ['--version']))
+ await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
return true
} catch {
return false
diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts
index cc005da88e6..b68c3e8afb5 100644
--- a/src/main/ipc/preflight-host-cli-status.test.ts
+++ b/src/main/ipc/preflight-host-cli-status.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -13,6 +14,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -28,6 +30,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
+ listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
-vi.mock('./command-path-resolver', () => ({
- isCommandOnLocalPath: isCommandOnLocalPathMock
+vi.mock('./command-path-resolver', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isCommandOnLocalPath: isCommandOnLocalPathMock,
+ listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -115,6 +120,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
@@ -162,17 +168,29 @@ describe('preflight', () => {
})
})
- it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => {
- execFileAsyncMock
- .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
- .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
- .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
- .mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
- .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
+ it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => {
+ const first = `/test/first/${cli}`
+ const second = `/test/second/${cli}`
+ listLocalCommandPathsMock.mockImplementation(async (command: string) =>
+ command === cli ? [first, second] : []
+ )
+ execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
+ if (command === first && args[0] === 'auth') {
+ throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' })
+ }
+ return { stdout: 'fixture success', stderr: '' }
+ })
const status = await runPreflightCheck()
- expect(status.gh).toEqual({ installed: true, authenticated: false })
+ expect(cli === 'gh' ? status.gh : status.glab).toEqual({
+ installed: true,
+ authenticated: false
+ })
+ expect(
+ execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args)
+ ).toEqual([['--version'], ['auth', 'status']])
+ expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(false)
})
it('keeps older gh stderr success output from showing a false auth warning', async () => {
@@ -188,6 +206,88 @@ describe('preflight', () => {
expect(status.gh).toEqual({ installed: true, authenticated: true })
})
+ // Why (#22975): these two cases key the spawn mock on (command, args) instead
+ // of call order, because the fallback probes exactly which copy runs is the
+ // behaviour under test — and `gh auth status` runs in parallel with
+ // `glab auth status`, so a fixed sequence would not be the real one.
+ it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => {
+ const shim = '/Users/octocat/.asdf/shims/gh'
+ const working = '/opt/homebrew/bin/gh'
+ const glabShim = '/Users/octocat/.asdf/shims/glab'
+ const glabWorking = '/usr/local/bin/glab'
+ const shims = new Set([shim, glabShim])
+ listLocalCommandPathsMock.mockImplementation(async (command: string) => {
+ if (command === 'gh') {
+ return [shim, working]
+ }
+ return command === 'glab' ? [glabShim, glabWorking] : []
+ })
+ execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
+ if (shims.has(command)) {
+ throw Object.assign(new Error('spawn failed'), { code: 126 })
+ }
+ const auth = args[0] === 'auth'
+ if (command === working) {
+ return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' }
+ }
+ if (command === glabWorking) {
+ return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' }
+ }
+ if (command === 'git') {
+ return { stdout: 'git version 2.0.0\n' }
+ }
+ throw new Error(`unexpected command ${command}`)
+ })
+
+ const status = await runPreflightCheck()
+
+ expect(status).toMatchObject({
+ gh: { installed: true, authenticated: true },
+ glab: { installed: true, authenticated: true }
+ })
+ for (const [cli, copy] of [
+ ['gh', working],
+ ['glab', glabWorking]
+ ]) {
+ expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], {
+ encoding: 'utf-8',
+ timeout: 5000,
+ windowsHide: true
+ })
+ expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], {
+ encoding: 'utf-8',
+ timeout: 5000,
+ windowsHide: true
+ })
+ }
+ })
+
+ it('marks gh not installed when no copy PATH offers can run', async () => {
+ const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh']
+ listLocalCommandPathsMock.mockImplementation(async (command: string) =>
+ command === 'gh' ? doomed : []
+ )
+ execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
+ if (doomed.includes(command)) {
+ throw Object.assign(new Error('spawn failed'), { code: 126 })
+ }
+ if (command === 'git') {
+ return { stdout: 'git version 2.0.0\n' }
+ }
+ if (command === 'glab') {
+ return {
+ stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n'
+ }
+ }
+ throw new Error(`unexpected command ${command}`)
+ })
+
+ const status = await runPreflightCheck()
+
+ expect(status.gh).toEqual({ installed: false, authenticated: false })
+ expect(execFileAsyncMock).toHaveBeenCalledTimes(5)
+ })
+
it('marks glab as not installed when `glab --version` fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
diff --git a/src/main/ipc/preflight-provider-command-selection.test.ts b/src/main/ipc/preflight-provider-command-selection.test.ts
new file mode 100644
index 00000000000..a51ac4a886c
--- /dev/null
+++ b/src/main/ipc/preflight-provider-command-selection.test.ts
@@ -0,0 +1,321 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { removeTree } from '../../shared/windows-transient-lock-removal'
+import { ghExecFileAsync } from '../git/command-runner/gh-exec-file'
+import { glabExecFileAsync } from '../git/command-runner/glab-exec-file'
+import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture'
+import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver'
+import {
+ execLocalPreflightCommandOrThrow,
+ findRunnableLocalCommand,
+ shellQuote
+} from './preflight-command-exec'
+
+vi.mock('../../shared/system-cli-install-dirs', () => ({
+ getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [
+ path.join(home, '.nix-profile', 'bin')
+ ]
+}))
+
+let root = ''
+const CLIS = ['gh', 'glab'] as const
+
+async function fixtureCli(
+ cli: string,
+ label: string,
+ body = `case "$1" in\n--version) echo fixture-version;;\nauth) echo 'Logged in fixture';;\napi) echo '${label}-api';;\nesac\n`
+): Promise {
+ const dir = path.join(root, label)
+ await mkdir(dir, { recursive: true })
+ const binary = path.join(dir, cli)
+ await writeFile(
+ binary,
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`,
+ { mode: 0o755 }
+ )
+ return binary
+}
+
+async function calls(binary: string): Promise {
+ try {
+ return (await readFile(`${binary}-calls`, 'utf8')).trim().split('\n')
+ } catch (error) {
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
+ return []
+ }
+ throw error
+ }
+}
+
+function providerCommand(cli: 'gh' | 'glab', env?: NodeJS.ProcessEnv, cwd?: string) {
+ const execute = cli === 'gh' ? ghExecFileAsync : glabExecFileAsync
+ return execute(['api', 'user'], { timeout: 1000, idempotent: false, env, cwd })
+}
+
+describe.skipIf(process.platform === 'win32')(
+ 'preflight selection in native provider commands',
+ () => {
+ beforeEach(async () => {
+ root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-'))
+ vi.stubEnv('HOME', root)
+ })
+
+ afterEach(async () => {
+ vi.unstubAllEnvs()
+ await removeTree(root)
+ })
+
+ it.each(CLIS)(
+ 'uses the same runnable %s for version, auth and provider operations',
+ async (cli) => {
+ const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
+ const good = await fixtureCli(cli, 'good')
+ vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
+
+ const selected = await findRunnableLocalCommand(cli)
+ expect(selected).toEqual({ status: 'available', binary: good })
+ if (selected.status !== 'available') {
+ throw new Error('Expected the working fixture to be selected')
+ }
+ await expect(
+ execLocalPreflightCommandOrThrow(selected.binary, ['auth', 'status'])
+ ).resolves.toMatchObject({ stdout: 'Logged in fixture\n' })
+ await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
+ stdout: 'good-api\n'
+ })
+ expect(await calls(shim)).toEqual(['--version'])
+ expect(await calls(good)).toEqual(['--version', 'auth status', 'api user'])
+ }
+ )
+
+ it.each(CLIS)(
+ 'keeps the proven %s usable while its version selection refreshes',
+ async (cli) => {
+ const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
+ const good = await fixtureCli(cli, 'good')
+ vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
+ await findRunnableLocalCommand(cli)
+
+ const refreshing = findRunnableLocalCommand(cli)
+ await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'good-api\n' })
+ await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
+ expect(await calls(shim)).toEqual(['--version', '--version'])
+ }
+ )
+
+ it('clears the previous selection only after the newest version probe fails', async () => {
+ const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
+ const good = await fixtureCli(
+ 'gh',
+ 'good',
+ 'if [ "$1" = --version ] && [ "$ORCA_22975_VERSION_DISABLED" = 1 ]; then exit 126; fi\necho good-api\n'
+ )
+ vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
+ await findRunnableLocalCommand('gh')
+ vi.stubEnv('ORCA_22975_VERSION_DISABLED', '1')
+
+ const refreshing = findRunnableLocalCommand('gh')
+ await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
+ await expect(refreshing).resolves.toMatchObject({ status: 'exec_failed' })
+ expect(resolveSelectedLocalCommand('gh')).toBe('gh')
+ })
+
+ it.each(CLIS)('also uses the recovered %s from a known install directory', async (cli) => {
+ const shim = await fixtureCli(cli, 'shim', 'exit 126\n')
+ const good = await fixtureCli(cli, path.join('.nix-profile', 'bin'))
+ vi.stubEnv('PATH', path.dirname(shim))
+
+ await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
+ status: 'available',
+ binary: good
+ })
+ await expect(providerCommand(cli)).resolves.toMatchObject({
+ stdout: `${path.join('.nix-profile', 'bin')}-api\n`
+ })
+ expect(await calls(shim)).toEqual(['--version'])
+ })
+
+ it.each(CLIS)('keeps %s selected when authentication fails', async (cli) => {
+ const first = await fixtureCli(
+ cli,
+ 'first',
+ 'if [ "$1" = auth ]; then echo unauthenticated >&2; exit 1; fi\necho first-result\n'
+ )
+ const other = await fixtureCli(cli, 'other')
+ vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
+
+ await findRunnableLocalCommand(cli)
+ await expect(
+ execLocalPreflightCommandOrThrow(first, ['auth', 'status'])
+ ).rejects.toMatchObject({
+ code: 1
+ })
+ await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'first-result\n' })
+ expect(await calls(other)).toEqual([])
+ })
+
+ it.each(CLIS)('never retries a failed %s operation on another binary', async (cli) => {
+ const first = await fixtureCli(
+ cli,
+ 'first',
+ 'if [ "$1" = api ]; then echo operation-failed >&2; exit 126; fi\necho fixture-version\n'
+ )
+ const other = await fixtureCli(cli, 'other')
+ vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
+
+ await findRunnableLocalCommand(cli)
+ await expect(providerCommand(cli)).rejects.toMatchObject({
+ code: 126,
+ stderr: 'operation-failed\n'
+ })
+ expect(await calls(first)).toEqual(['--version', 'api user'])
+ expect(await calls(other)).toEqual([])
+ })
+
+ it('respects a caller PATH and explicit binary instead of the global selection', async () => {
+ const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
+ const good = await fixtureCli('gh', 'good')
+ const custom = await fixtureCli('gh', 'custom')
+ vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
+ await findRunnableLocalCommand('gh')
+ const refreshing = findRunnableLocalCommand('gh')
+
+ await expect(
+ providerCommand('gh', { ...process.env, PATH: path.dirname(custom) })
+ ).resolves.toMatchObject({ stdout: 'custom-api\n' })
+ await expect(
+ execFileCaptureToTermination(custom, ['api', 'user'], { encoding: 'utf8', timeout: 1000 })
+ ).resolves.toMatchObject({ stdout: 'custom-api\n' })
+ await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
+ await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
+ })
+
+ it('bypasses a selection after PATH or relative-PATH cwd changes', async () => {
+ const good = await fixtureCli('gh', 'good')
+ const custom = await fixtureCli('gh', 'custom')
+ vi.stubEnv('PATH', path.dirname(good))
+ await findRunnableLocalCommand('gh')
+ const refreshing = findRunnableLocalCommand('gh')
+ vi.stubEnv('PATH', path.dirname(custom))
+ await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
+ await refreshing
+ await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
+
+ vi.stubEnv('PATH', path.relative(process.cwd(), path.dirname(good)))
+ await findRunnableLocalCommand('gh')
+ expect(resolveSelectedLocalCommand('gh', { cwd: root })).toBe('gh')
+ })
+
+ it('discards a replaced binary and clears a selection when the next probe fails', async () => {
+ const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
+ const good = await fixtureCli('gh', 'good')
+ vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
+ await findRunnableLocalCommand('gh')
+ await writeFile(good, '#!/bin/sh\necho changed >&2\nexit 126\n')
+
+ expect(resolveSelectedLocalCommand('gh')).toBe('gh')
+ await expect(findRunnableLocalCommand('gh')).resolves.toMatchObject({ status: 'exec_failed' })
+ expect(resolveSelectedLocalCommand('gh')).toBe('gh')
+ await fixtureCli('gh', 'good')
+ await findRunnableLocalCommand('gh')
+ expect(resolveSelectedLocalCommand('gh')).toBe(good)
+ await rm(good)
+ expect(resolveSelectedLocalCommand('gh')).toBe('gh')
+ })
+
+ it('keeps an older concurrent probe from replacing the newer selection', async () => {
+ const first = await fixtureCli('gh', 'first')
+ const second = await fixtureCli('gh', 'second')
+ const older = beginLocalCommandSelection('gh')
+ const newer = beginLocalCommandSelection('gh')
+ await newer(second)
+ await older(first)
+ await older(null)
+
+ expect(resolveSelectedLocalCommand('gh')).toBe(second)
+ })
+ }
+)
+
+describe('Windows selection across working folders', () => {
+ it('keeps an absolute PATH selection in a different provider cwd', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-cwd-'))
+ const binary = path.join(directory, 'gh.CMD')
+ const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+ await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
+ try {
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ vi.stubEnv('PATH', directory)
+ const publish = beginLocalCommandSelection('gh')
+ await publish(binary)
+ expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe(
+ binary
+ )
+ } finally {
+ vi.unstubAllEnvs()
+ if (descriptor) {
+ Object.defineProperty(process, 'platform', descriptor)
+ }
+ await removeTree(directory)
+ }
+ })
+
+ it('does not carry a current-directory CLI into another folder', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-local-cwd-'))
+ const binary = path.join(directory, 'gh.CMD')
+ const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
+ await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
+ const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory)
+ try {
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ vi.stubEnv('PATH', path.join(directory, 'other'))
+ const publish = beginLocalCommandSelection('gh')
+ await publish(binary)
+ expect(resolveSelectedLocalCommand('gh')).toBe(binary)
+ expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe('gh')
+ } finally {
+ cwd.mockRestore()
+ vi.unstubAllEnvs()
+ if (descriptor) {
+ Object.defineProperty(process, 'platform', descriptor)
+ }
+ await removeTree(directory)
+ }
+ })
+})
+
+describe.runIf(process.platform === 'win32')('native provider batch selection', () => {
+ beforeEach(async () => {
+ root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-cmd-'))
+ })
+
+ afterEach(async () => {
+ vi.unstubAllEnvs()
+ await removeTree(root)
+ })
+
+ it.each(CLIS)('passes the selected %s.cmd through the native runner', async (cli) => {
+ const shim = path.join(root, 'shim')
+ const good = path.join(root, 'good')
+ await mkdir(shim)
+ await mkdir(good)
+ await writeFile(path.join(shim, `${cli}.CMD`), '@echo off\r\nexit /b 126\r\n')
+ await writeFile(
+ path.join(good, `${cli}.CMD`),
+ '@echo off\r\nif "%~1"=="api" (echo good-api) else (echo fixture-version)\r\nexit /b 0\r\n'
+ )
+ vi.stubEnv('PATH', [shim, good].join(path.delimiter))
+ vi.stubEnv('Path', [shim, good].join(path.delimiter))
+ vi.stubEnv('PATHEXT', '.CMD')
+
+ await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
+ status: 'available',
+ binary: path.posix.join(good, `${cli}.CMD`)
+ })
+ await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
+ stdout: expect.stringContaining('good-api')
+ })
+ })
+})
diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts
index 14d79e953e8..4ad02ec5d04 100644
--- a/src/main/ipc/preflight-remote-ssh.test.ts
+++ b/src/main/ipc/preflight-remote-ssh.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
+ listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -58,8 +61,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
-vi.mock('./command-path-resolver', () => ({
- isCommandOnLocalPath: isCommandOnLocalPathMock
+vi.mock('./command-path-resolver', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isCommandOnLocalPath: isCommandOnLocalPathMock,
+ listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -106,6 +111,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
diff --git a/src/main/ipc/preflight-runnable-local-cli.test.ts b/src/main/ipc/preflight-runnable-local-cli.test.ts
new file mode 100644
index 00000000000..2f41efd7b78
--- /dev/null
+++ b/src/main/ipc/preflight-runnable-local-cli.test.ts
@@ -0,0 +1,249 @@
+import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { removeTree } from '../../shared/windows-transient-lock-removal'
+import {
+ execLocalPreflightCommandOrThrow,
+ findRunnableLocalCommand,
+ isCommandAvailable,
+ isCommandOnPath,
+ shellQuote
+} from './preflight-command-exec'
+
+const COMMAND = 'orca-22975-gh'
+const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n'
+let root = ''
+
+async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n'): Promise {
+ const dir = path.join(root, label)
+ await mkdir(dir, { recursive: true })
+ await writeFile(
+ path.join(dir, COMMAND),
+ `#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`,
+ { mode: 0o755 }
+ )
+ return dir
+}
+
+async function probedArgs(dir: string): Promise {
+ try {
+ return (await readFile(path.join(dir, 'argv.txt'), 'utf8')).split('\n').filter(Boolean)
+ } catch (error) {
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
+ return []
+ }
+ throw error
+ }
+}
+
+describe.skipIf(process.platform === 'win32')(
+ 'local CLI version probes with real processes',
+ () => {
+ beforeEach(async () => {
+ root = await mkdtemp(path.join(tmpdir(), 'orca-22975-case-'))
+ vi.stubEnv('HOME', root)
+ })
+
+ afterEach(async () => {
+ vi.unstubAllEnvs()
+ await removeTree(root)
+ })
+
+ it('detects a runnable copy behind an executable shim that exits 126', async () => {
+ const shim = await cliInDirectory('shim', BROKEN_SHIM)
+ const good = await cliInDirectory('good')
+ vi.stubEnv('PATH', [shim, good].join(path.delimiter))
+
+ await expect(isCommandOnPath(COMMAND)).resolves.toBe(true)
+ await expect(
+ execLocalPreflightCommandOrThrow(path.join(shim, COMMAND), ['--version'])
+ ).rejects.toMatchObject({ code: 126, stderr: 'broken-shim\n' })
+ await expect(
+ execLocalPreflightCommandOrThrow(path.join(good, COMMAND), ['--version'])
+ ).resolves.toMatchObject({ stdout: 'gh-version-fixture\n' })
+ await expect(isCommandAvailable(COMMAND)).resolves.toBe(true)
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(good, COMMAND)
+ })
+ })
+
+ it('keeps PATH order and leaves later copies unprobed when the first works', async () => {
+ const first = await cliInDirectory('first')
+ const second = await cliInDirectory('second')
+ vi.stubEnv('PATH', [first, second].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(first, COMMAND)
+ })
+ expect(await probedArgs(first)).toEqual(['--version'])
+ expect(await probedArgs(second)).toEqual([])
+ })
+
+ it('distinguishes exhausted failing copies from an absent command', async () => {
+ const first = await cliInDirectory('first', BROKEN_SHIM)
+ const second = await cliInDirectory('second', BROKEN_SHIM)
+ vi.stubEnv('PATH', [first, second].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'exec_failed',
+ binary: path.join(second, COMMAND)
+ })
+ await expect(findRunnableLocalCommand('orca-22975-absent-cli')).resolves.toEqual({
+ status: 'absent'
+ })
+ expect(await probedArgs(first)).toEqual(['--version'])
+ expect(await probedArgs(second)).toEqual(['--version'])
+ })
+
+ it('skips directories and non-executable files before a working copy', async () => {
+ const directory = path.join(root, 'directory')
+ await mkdir(path.join(directory, COMMAND), { recursive: true })
+ const plain = await cliInDirectory('plain')
+ await chmod(path.join(plain, COMMAND), 0o644)
+ const good = await cliInDirectory('good')
+ vi.stubEnv('PATH', [directory, plain, good].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(good, COMMAND)
+ })
+ expect(await probedArgs(plain)).toEqual([])
+ })
+
+ it('never substitutes another binary for an explicit failing path', async () => {
+ const shim = await cliInDirectory('shim', BROKEN_SHIM)
+ const good = await cliInDirectory('good')
+ vi.stubEnv('PATH', good)
+ const selected = path.join(shim, COMMAND)
+
+ await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
+ status: 'exec_failed',
+ binary: selected
+ })
+ expect(await probedArgs(good)).toEqual([])
+ })
+
+ it('executes relative explicit paths and reports an explicit missing path', async () => {
+ const good = await cliInDirectory('good')
+ const selected = path.relative(process.cwd(), path.join(good, COMMAND))
+ vi.stubEnv('PATH', '')
+
+ await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
+ status: 'available',
+ binary: selected
+ })
+ await expect(findRunnableLocalCommand(path.join(root, 'missing', COMMAND))).resolves.toEqual({
+ status: 'absent'
+ })
+ })
+
+ it.each([true, false])(
+ 'preserves relative PATH order with shim first: %s',
+ async (shimFirst) => {
+ const good = await cliInDirectory('good')
+ const shim = await cliInDirectory('shim', BROKEN_SHIM)
+ const relative = path.relative(process.cwd(), good)
+ vi.stubEnv('PATH', (shimFirst ? [shim, relative] : [relative, shim]).join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(good, COMMAND)
+ })
+ expect(await probedArgs(good)).toEqual(['--version'])
+ expect(await probedArgs(shim)).toEqual(shimFirst ? ['--version'] : [])
+ }
+ )
+
+ it('deduplicates PATH entries before spending the probe limit', async () => {
+ const shim = await cliInDirectory('shim', BROKEN_SHIM)
+ const good = await cliInDirectory('good')
+ vi.stubEnv('PATH', [shim, shim, shim, shim, good].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(good, COMMAND)
+ })
+ expect(await probedArgs(shim)).toEqual(['--version'])
+ })
+
+ it('caps execution at four distinct candidates and reports the limit', async () => {
+ const doomed: string[] = []
+ for (const index of [0, 1, 2, 3]) {
+ doomed.push(await cliInDirectory(`doomed-${index}`, BROKEN_SHIM))
+ }
+ const good = await cliInDirectory('past-cap')
+ vi.stubEnv('PATH', [...doomed, good].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'limit_reached',
+ binary: path.join(doomed[3], COMMAND)
+ })
+ for (const dir of doomed) {
+ expect(await probedArgs(dir)).toEqual(['--version'])
+ }
+ expect(await probedArgs(good)).toEqual([])
+ })
+
+ it('stops at a real timeout and leaves later copies unprobed', async () => {
+ const hung = await cliInDirectory('hung', 'exec /bin/sleep 30\n')
+ const good = await cliInDirectory('good')
+ vi.stubEnv('PATH', [hung, good].join(path.delimiter))
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'timeout',
+ binary: path.join(hung, COMMAND)
+ })
+ expect(await probedArgs(good)).toEqual([])
+ }, 10_000)
+
+ it('uses the known Nix install directory after PATH copies fail', async () => {
+ const shim = await cliInDirectory('shim', BROKEN_SHIM)
+ const profile = await cliInDirectory(path.join('.nix-profile', 'bin'))
+ vi.stubEnv('PATH', shim)
+
+ await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
+ status: 'available',
+ binary: path.join(profile, COMMAND)
+ })
+ })
+ }
+)
+
+describe.runIf(process.platform === 'win32')('Windows preflight batch shims', () => {
+ beforeEach(async () => {
+ root = await mkdtemp(path.join(tmpdir(), 'orca-22975-cmd-'))
+ })
+
+ afterEach(async () => {
+ vi.unstubAllEnvs()
+ await removeTree(root)
+ })
+
+ it('runs the next cmd shim after an earlier copy exits 126', async () => {
+ const first = path.join(root, 'first')
+ const second = path.join(root, 'second')
+ for (const dir of [first, second]) {
+ await mkdir(dir)
+ }
+ const name = `${COMMAND}.CMD`
+ await writeFile(path.join(first, name), '@echo off\r\nexit /b 126\r\n')
+ await writeFile(
+ path.join(second, name),
+ '@echo off\r\necho gh-version-fixture\r\nexit /b 0\r\n'
+ )
+ vi.stubEnv('PATH', [first, second].join(path.delimiter))
+ vi.stubEnv('Path', [first, second].join(path.delimiter))
+
+ const result = await findRunnableLocalCommand(COMMAND)
+
+ expect(result).toEqual({ status: 'available', binary: path.posix.join(second, name) })
+ if (result.status === 'available') {
+ await expect(
+ execLocalPreflightCommandOrThrow(result.binary, ['auth', 'status'])
+ ).resolves.toMatchObject({ stdout: expect.stringContaining('gh-version-fixture') })
+ }
+ })
+})
diff --git a/src/main/ipc/preflight-test-harness.ts b/src/main/ipc/preflight-test-harness.ts
index abdee69985c..e38848ad5eb 100644
--- a/src/main/ipc/preflight-test-harness.ts
+++ b/src/main/ipc/preflight-test-harness.ts
@@ -16,6 +16,7 @@ export type PreflightMocks = {
getGiteaAuthStatusMock: Mock
resolveCliCommandsMock: Mock
isCommandOnLocalPathMock: Mock
+ listLocalCommandPathsMock: Mock
mergePersistedWindowsPathAsyncMock: Mock
mergePersistedWindowsPathMock: Mock
}
@@ -52,6 +53,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
+ listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = mocks
@@ -63,6 +65,11 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
mergePathSegmentsMock.mockReset()
getActiveMultiplexerMock.mockReset()
+ // Why empty by default: with no fs candidates the local probe keeps its
+ // historical bare-name spawn, so cases that stub only where/which and execFile
+ // still assert on the command names they were written against.
+ listLocalCommandPathsMock.mockReset()
+ listLocalCommandPathsMock.mockResolvedValue([])
getBitbucketAuthStatusMock.mockReset()
getAzureDevOpsAuthStatusMock.mockReset()
getGiteaAuthStatusMock.mockReset()
diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts
index 004735c61cb..50b67963ab9 100644
--- a/src/main/ipc/pty-controller-process-inventory.test.ts
+++ b/src/main/ipc/pty-controller-process-inventory.test.ts
@@ -1,3 +1,4 @@
+import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
})
}))
-vi.mock('../opencode/hook-service', () => ({
- openCodeHookService: {
- buildPtyEnv: () => ({}),
- refreshLegacySharedPlugin: vi.fn(),
- clearPty: vi.fn()
- },
- openCode2HookService: {
- buildPtyEnv: () => ({}),
- refreshLegacySharedPlugin: vi.fn(),
- clearPty: vi.fn()
- }
-}))
+vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts
index f49978843d5..4eba8d0b164 100644
--- a/src/main/ipc/pty-ipc-mock-registry.ts
+++ b/src/main/ipc/pty-ipc-mock-registry.ts
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({
})
export const openCodeHookServiceModuleMock = () => ({
+ OpenCodeHookService: class {
+ buildPtyEnv = vi.fn(() => ({}))
+ },
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
refreshLegacySharedPlugin: vi.fn<() => void>(),
diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts
index 224238bff9a..d530a9cd194 100644
--- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts
+++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts
@@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir'
import { makePaneKey } from '../../shared/stable-pane-id'
+import { selectShellStartupFeatures } from '../shell-startup-features'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
describe('spawn environment', () => {
+ it.each(['/bin/bash', '/bin/zsh'])(
+ 'does not wrap a bare %s pane merely to expose this app CLI',
+ (shellPath) => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
+ try {
+ const env = buildPtyHostEnv(
+ 'bare-cli-pane',
+ {},
+ {
+ isPackaged: false,
+ userDataPath: '/tmp/orca-user-data',
+ selectedCodexHomePath: null,
+ agentStatusHooksEnabled: false
+ }
+ )
+ expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin')
+ expect(
+ selectShellStartupFeatures({
+ shellPath,
+ env,
+ hasStartupCommand: false,
+ waitsForShellReady: false,
+ emitsStartupIdentity: false
+ })
+ ).toEqual([])
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ }
+ )
+
it('does not install managed Pi extensions when Pi is disabled', () => {
piBuildPtyEnvMock.mockClear()
diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts
index 1cae20bfcbd..8b921b4ebde 100644
--- a/src/main/ipc/pty/host-env/assembly.ts
+++ b/src/main/ipc/pty/host-env/assembly.ts
@@ -1,15 +1,10 @@
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
-import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import {
detectExplicitPiAgentKindFromCommand,
isPiCompatibleAgentType
} from '../../../../shared/pi-agent-kind'
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
-import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
-import {
- OPENCODE_CONFIG_DIR_ENV_KEYS,
- isOpenCodeLegacySharedConfigDir
-} from '../../../opencode/legacy-shared-config-dir'
+import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
import { mimoCodeHookService } from '../../../mimo/hook-service'
import { agentHookServer } from '../../../agent-hooks/server'
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
@@ -28,13 +23,13 @@ import {
exposePiManagedExtensionEnv,
isMimoLaunchCommand,
resolveMimocodeSourceHome,
- resolveOpenCodeSourceConfigDir,
resolvePiAgentSourceDir,
resolveScopedPiAgentSourceDir,
restoreOrStripOverlayEnv
} from './pi-agent'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
+import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
mergePersistedWindowsPath(baseEnv)
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
- // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
- const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
- isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
- const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
- for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
- if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
- delete baseEnv[key]
- }
- if (!isLegacyOpenCodeHooksDir(process.env[key])) {
- inheritedOpenCodeEnv[key] = process.env[key]
- }
- }
- // A daemon or sibling shell can retain a retired path that main no longer sees.
- openCodeHookService.refreshLegacySharedPlugin()
- openCode2HookService.refreshLegacySharedPlugin()
- const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
- const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
- ? undefined
- : resolvedOpenCodeConfigDir
+ const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
- const openCodeAgent = selectOpenCodeHookAgent(
- opts.launchAgent,
- launchCommandHint,
- (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
- )
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
? opts.launchAgent
: opts.launchAgent === undefined
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
- restoreOrStripOverlayEnv(
+ const openCodeAgent = applyOpenCodeStatusPluginEnv(
+ id,
baseEnv,
- {
- primary: 'OPENCODE_CONFIG_DIR',
- overlay: 'ORCA_OPENCODE_CONFIG_DIR',
- source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
- preserveExplicitPrimary: true
- },
- inheritedOpenCodeEnv
+ openCodeConfig,
+ opts,
+ launchCommandHint
)
- delete baseEnv.ORCA_OPENCODE_AGENT
- if (openCodeAgent) {
- // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
- const openCodeStatusService =
- openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
- baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
- // WSL owns its config writes; only the guest overlay may enter a WSL pane.
- if (!opts.isWsl) {
- Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
- }
- if (baseEnv.OPENCODE_CONFIG_DIR) {
- // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
- baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
- if (preexistingOpenCodeConfigDir) {
- // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
- baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
- } else {
- delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
- }
- }
- }
if (opts.agentStatusHooksEnabled) {
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
// process.env when baseEnv carries none, which is the daemon path's normal shape.
stripLegacyTerminalShimEnv(baseEnv, process.platform)
+ if (!opts.isWsl) {
+ ensureOpenCodeStartupPromptForLaunch(baseEnv)
+ }
return baseEnv
}
diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts
new file mode 100644
index 00000000000..88dafabc88e
--- /dev/null
+++ b/src/main/ipc/pty/host-env/opencode-config.ts
@@ -0,0 +1,84 @@
+import {
+ OPENCODE_CONFIG_DIR_ENV_KEYS,
+ isOpenCodeLegacySharedConfigDir
+} from '../../../opencode/legacy-shared-config-dir'
+import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
+import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
+import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
+import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
+import type { BuildPtyHostEnvOptions } from './types'
+
+type OpenCodeSourceConfig = {
+ inheritedEnv: NodeJS.ProcessEnv
+ directory: string | undefined
+}
+
+export function captureOpenCodeSourceConfig(
+ env: Record,
+ userDataPath: string
+): OpenCodeSourceConfig {
+ const isLegacyDirectory = (dir: string | undefined): boolean =>
+ isOpenCodeLegacySharedConfigDir(dir, userDataPath)
+ const inheritedEnv: NodeJS.ProcessEnv = {}
+ for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
+ if (isLegacyDirectory(env[key])) {
+ delete env[key]
+ }
+ if (!isLegacyDirectory(process.env[key])) {
+ inheritedEnv[key] = process.env[key]
+ }
+ }
+ // A daemon or sibling shell can retain a retired path that main no longer sees.
+ openCodeHookService.refreshLegacySharedPlugin()
+ openCode2HookService.refreshLegacySharedPlugin()
+ const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv)
+ return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory }
+}
+
+export function applyOpenCodeStatusPluginEnv(
+ id: string,
+ env: Record,
+ config: OpenCodeSourceConfig,
+ options: Pick<
+ BuildPtyHostEnvOptions,
+ 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
+ >,
+ command: string | undefined
+): 'opencode' | 'opencode2' | null {
+ const agent = selectOpenCodeHookAgent(
+ options.launchAgent,
+ command,
+ (candidate) =>
+ options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents)
+ )
+ restoreOrStripOverlayEnv(
+ env,
+ {
+ primary: 'OPENCODE_CONFIG_DIR',
+ overlay: 'ORCA_OPENCODE_CONFIG_DIR',
+ source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
+ preserveExplicitPrimary: true
+ },
+ config.inheritedEnv
+ )
+ delete env.ORCA_OPENCODE_AGENT
+ if (!agent) {
+ return null
+ }
+ const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService
+ env.ORCA_OPENCODE_AGENT = agent
+ // WSL owns its config writes; only the guest overlay may enter a WSL pane.
+ if (!options.isWsl) {
+ Object.assign(env, service.buildPtyEnv(id, config.directory))
+ }
+ if (env.OPENCODE_CONFIG_DIR) {
+ // Shell startup can re-export the default; preserve this pane's overlay and original source.
+ env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR
+ if (config.directory) {
+ env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory
+ } else {
+ delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
+ }
+ }
+ return agent
+}
diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
index a52a9d70360..ddae2771aba 100644
--- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
+++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
@@ -77,6 +77,28 @@ afterEach(() => {
})
describe('OpenCode installation uses the current enabled agents', () => {
+ it('refuses spawn if a prepared startup intent loses its owned installer', () => {
+ mkdirSync(fixture.userData, { recursive: true })
+ writeFileSync(
+ join(fixture.userData, 'opencode-startup-prompt-overlays'),
+ 'blocked fixture root'
+ )
+ expect(() =>
+ buildPtyHostEnv(
+ 'owned-launch',
+ {
+ OPENCODE_CONFIG_DIR: custom,
+ ORCA_OPENCODE_PLUGIN_API: 'v2',
+ ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce',
+ ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief'
+ },
+ { ...options, agentStatusHooksEnabled: false }
+ )
+ ).toThrow('launch was canceled')
+ expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}')
+ expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin')
+ })
+
const combinations = [
{ disabled: [], fallback: 'opencode' },
{ disabled: ['opencode'], fallback: 'opencode2' },
diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts
index 77491c9fb11..24df59c1201 100644
--- a/src/main/ipc/pty/ipc/spawn-commit.ts
+++ b/src/main/ipc/pty/ipc/spawn-commit.ts
@@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
+import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise {
+ return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
+}
+
+async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
if (ctx.nativeWindowsConptySpawn) {
@@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx))
+}
+
+async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
@@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
+
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts
index d413c0556f0..26c3856b45e 100644
--- a/src/main/ipc/pty/runtime/spawn-options.ts
+++ b/src/main/ipc/pty/runtime/spawn-options.ts
@@ -35,6 +35,7 @@ import {
import type { RuntimePtySpawnState } from './spawn-state'
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch'
+import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch'
/** Headless spawns need the same host-side environment isolation as desktop spawns. */
export async function buildRuntimePtySpawnOptions(
@@ -85,20 +86,12 @@ export async function buildRuntimePtySpawnOptions(
? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData'))
: [],
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
- getInheritedAgentSessionStampEnvKeysToDelete(ctx.env)
- )
- if (ctx.skipCodexHomeEnv) {
- ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(
- ctx.spawnOptions.envToDelete,
- CODEX_HOME_ENV_KEYS
- )
- } else if (ctx.stripInheritedOrcaCodexHome) {
+ getInheritedAgentSessionStampEnvKeysToDelete(ctx.env),
+ ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
// Why: the daemon owns a persistent inherited environment that may
// differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair.
- ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [
- 'ORCA_CODEX_HOME'
- ])
- }
+ ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
+ )
if (ctx.codexResumeHomeSelected) {
ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete)
}
@@ -111,6 +104,21 @@ export async function buildRuntimePtySpawnOptions(
env: ctx.env,
envToDelete: ctx.spawnOptions.envToDelete
})
+ const openCodeLaunch = await prepareOpenCodePtyLaunch({
+ command: ctx.launchCommand,
+ agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
+ env: ctx.env,
+ envToDelete: (ctx.spawnOptions.envToDelete ??= []),
+ cwd: ctx.cwd,
+ connectionId: args.connectionId,
+ isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined,
+ ...(ctx.codexSelectionTarget.runtime === 'wsl'
+ ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
+ : {})
+ })
+ ctx.env = openCodeLaunch.env
+ ctx.launchCommand = openCodeLaunch.command
+ ctx.spawnOptions.env = ctx.env
promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath)
const noDaemonLaunch = planCodexNoDaemonLaunch({
command: ctx.launchCommand,
diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts
index b82e6a417b9..17dbcb379c2 100644
--- a/src/main/jcode/hook-config.test.ts
+++ b/src/main/jcode/hook-config.test.ts
@@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh
})
it('repoints a managed entry left behind by a copied home or a platform switch', () => {
- // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands
- // the exact script path — a stale entry stuck the install on `partial` forever
- // with no Orca action able to repair it.
const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh'
const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n`
const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh')
diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts
index 3a87aa4ef6a..f86fe52444c 100644
--- a/src/main/jcode/hook-service.test.ts
+++ b/src/main/jcode/hook-service.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service'
import {
getJcodeConfigPath,
getJcodeManagedCommand,
+ getJcodeManagedScriptFileName,
getJcodeManagedScriptPath,
JCODE_HOOK_EVENTS
} from './hook-settings'
@@ -72,6 +73,123 @@ describe('JcodeHookService', () => {
expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"')
})
+ it('reports a missing managed script and repairs it without changing config', () => {
+ const service = new JcodeHookService()
+ service.install()
+ const config = readFileSync(getJcodeConfigPath(), 'utf8')
+ rmSync(getJcodeManagedScriptPath())
+
+ expect(service.getStatus()).toMatchObject({
+ state: 'partial',
+ managedHooksPresent: true,
+ detail: 'Managed hook script missing'
+ })
+ expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
+ expect(service.install().state).toBe('installed')
+ expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode')
+ expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
+ })
+
+ it('reports missing scripts alongside incomplete event coverage and user hooks', () => {
+ const service = new JcodeHookService()
+ service.install()
+ writeFileSync(
+ getJcodeConfigPath(),
+ `[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`,
+ 'utf8'
+ )
+ rmSync(getJcodeManagedScriptPath())
+
+ const status = service.getStatus()
+ expect(status.state).toBe('partial')
+ expect(status.detail).toContain('Managed hook script missing')
+ expect(status.detail).toContain(
+ 'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end'
+ )
+ expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
+ expect(service.install().state).toBe('partial')
+ expect(service.getStatus().detail).not.toContain('script missing')
+ expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain(
+ 'turn_end = "~/bin/my-turn-notify"'
+ )
+ })
+
+ it.each([
+ '/Users/previous/.orca/agent-hooks/jcode-hook.sh',
+ 'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd'
+ ])('recognizes a stale managed command %s without a local script', (stalePath) => {
+ const service = new JcodeHookService()
+ const configPath = getJcodeConfigPath()
+ mkdirSync(dirname(configPath), { recursive: true })
+ writeFileSync(
+ configPath,
+ `[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`,
+ 'utf8'
+ )
+
+ const status = service.getStatus()
+ expect(status.state).toBe('partial')
+ expect(status.managedHooksPresent).toBe(true)
+ expect(status.detail).toContain('Managed hook command outdated for events: turn_end')
+ expect(status.detail).toContain('Managed hook script missing')
+ expect(status.detail).not.toContain('User-owned')
+ expect(service.install().state).toBe('installed')
+ expect(readFileSync(configPath, 'utf8')).not.toContain('previous')
+ })
+
+ it('does not report complete stale event coverage as installed when the current script exists', () => {
+ const service = new JcodeHookService()
+ service.install()
+ const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh')
+ writeFileSync(
+ getJcodeConfigPath(),
+ `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`,
+ 'utf8'
+ )
+
+ const status = service.getStatus()
+ expect(status.state).toBe('partial')
+ expect(status.managedHooksPresent).toBe(true)
+ expect(status.detail).toBe(
+ `Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}`
+ )
+ expect(service.install().state).toBe('installed')
+ })
+
+ it('reports legacy unquoted commands as outdated even when the script exists', () => {
+ const service = new JcodeHookService()
+ service.install()
+ writeFileSync(
+ getJcodeConfigPath(),
+ `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`,
+ 'utf8'
+ )
+
+ const status = service.getStatus()
+ expect(status.state).toBe('partial')
+ expect(status.detail).toContain('Managed hook command outdated for events:')
+ expect(status.detail).not.toContain('script missing')
+ expect(status.detail).not.toContain('User-owned')
+ expect(service.install().state).toBe('installed')
+ })
+
+ it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => {
+ const configPath = getJcodeConfigPath()
+ mkdirSync(dirname(configPath), { recursive: true })
+ writeFileSync(
+ configPath,
+ `[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`,
+ 'utf8'
+ )
+
+ const status = new JcodeHookService().getStatus()
+ expect(status.state).toBe('partial')
+ expect(status.managedHooksPresent).toBe(false)
+ expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
+ expect(status.detail).not.toContain('command outdated')
+ expect(status.detail).not.toContain('script missing')
+ })
+
it('preserves unrelated config tables when installing hooks', () => {
const configPath = getJcodeConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
@@ -107,6 +225,8 @@ describe('JcodeHookService', () => {
expect(before).toContain('turn_end')
const status = new JcodeHookService().remove()
expect(status.state).toBe('not_installed')
+ expect(status.detail).toBeNull()
+ expect(existsSync(getJcodeManagedScriptPath())).toBe(true)
const after = readFileSync(getJcodeConfigPath(), 'utf8')
expect(after).not.toContain(getJcodeManagedScriptPath())
})
diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts
index ebe41a2dc36..eff119f91d5 100644
--- a/src/main/jcode/hook-service.ts
+++ b/src/main/jcode/hook-service.ts
@@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookPostCommand,
+ createManagedCommandMatcher,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
@@ -128,33 +129,42 @@ export class JcodeHookService {
}
const scriptPresent = existsSync(scriptPath)
const managedCommand = getJcodeManagedCommand(scriptPath)
+ const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName())
const missing: string[] = []
+ const outdated: string[] = []
const userOwned: string[] = []
let managedCount = 0
for (const event of JCODE_HOOK_EVENTS) {
const value = table[event]
- // Why both forms: installs before the quoting fix stored the bare path, and
- // install() repoints those — reporting them user-owned would hide the repair.
- if (value === managedCommand || value === scriptPath) {
+ if (value === managedCommand) {
managedCount += 1
+ } else if (isManaged(value)) {
+ outdated.push(event)
} else if (value === undefined) {
missing.push(event)
} else {
userOwned.push(event)
}
}
- const managedHooksPresent = managedCount > 0 || scriptPresent
+ const hasManagedEntries = managedCount > 0 || outdated.length > 0
+ const managedHooksPresent = hasManagedEntries || scriptPresent
let state: AgentHookInstallState
let detail: string | null
- if (missing.length === 0 && userOwned.length === 0) {
+ if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) {
state = 'installed'
detail = null
- } else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) {
+ } else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
const parts: string[] = []
+ if (hasManagedEntries && !scriptPresent) {
+ parts.push('Managed hook script missing')
+ }
+ if (outdated.length > 0) {
+ parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`)
+ }
if (missing.length > 0) {
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
}
diff --git a/src/main/macos-update-running-instances.integration.test.ts b/src/main/macos-update-running-instances.integration.test.ts
new file mode 100644
index 00000000000..afc5c3d7e66
--- /dev/null
+++ b/src/main/macos-update-running-instances.integration.test.ts
@@ -0,0 +1,97 @@
+import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { once } from 'node:events'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { expect, it, vi } from 'vitest'
+import { runProcess, spawnProcess } from '../shared/child-process/run-process'
+import { getMacUpdateRunningInstances } from './macos-update-running-instances'
+
+const APPLICATION_SOURCE = `
+#import
+#import
+int main(int argc, const char *argv[]) {
+ if (argc > 1) { sleep(30); return 0; }
+ @autoreleasepool {
+ NSApplication *app = [NSApplication sharedApplication];
+ [app setActivationPolicy:NSApplicationActivationPolicyProhibited];
+ [app run];
+ }
+ return 0;
+}
+`
+
+it.runIf(process.platform === 'darwin')(
+ 'matches ShipIt with registered sibling apps, excluding same-executable workers and other bundle copies',
+ async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'orca-update-instances-'))
+ const bundle = path.join(root, 'Orca Test.app')
+ const executable = path.join(bundle, 'Contents', 'MacOS', 'Orca Test')
+ mkdirSync(path.dirname(executable), { recursive: true })
+ const sourcePath = path.join(root, 'application.m')
+ writeFileSync(sourcePath, APPLICATION_SOURCE)
+ writeFileSync(
+ path.join(bundle, 'Contents', 'Info.plist'),
+ `
+ CFBundleIdentifiercom.stablyai.${path.basename(root)}
+ CFBundleExecutableOrca Test
+ CFBundlePackageTypeAPPL
+ `
+ )
+ const children: ReturnType[] = []
+ const closed: Promise[] = []
+ try {
+ const compilation = await runProcess({
+ program: '/usr/bin/clang',
+ args: ['-framework', 'AppKit', sourcePath, '-o', executable],
+ timeoutMs: 15_000
+ })
+ expect(compilation.code, compilation.stderr).toBe(0)
+ const otherBundle = path.join(root, 'Other Orca.app')
+ cpSync(bundle, otherBundle, { recursive: true })
+ for (const [program, args] of [
+ [executable, []],
+ [executable, []],
+ [executable, ['--worker']],
+ [path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), []]
+ ] satisfies [string, string[]][]) {
+ const child = spawnProcess({
+ program,
+ args,
+ env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
+ })
+ children.push(child)
+ closed.push(once(child, 'close'))
+ }
+ const [self, sibling, worker, otherCopy] = children
+ await vi.waitFor(
+ async () => {
+ expect(self.pid).toBeTypeOf('number')
+ expect(sibling.pid).toBeTypeOf('number')
+ expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([sibling.pid])
+ expect(
+ await getMacUpdateRunningInstances(
+ path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'),
+ 0
+ )
+ ).toEqual([otherCopy.pid])
+ const workerListing = await runProcess({
+ program: '/bin/ps',
+ args: ['-p', String(worker.pid), '-ww', '-o', 'comm=']
+ })
+ expect(workerListing.stdout.trim()).toBe(executable)
+ },
+ { timeout: 5000 }
+ )
+ sibling.kill('SIGTERM')
+ await closed[1]
+ expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([])
+ } finally {
+ for (const child of children) {
+ child.kill('SIGTERM')
+ }
+ await Promise.all(closed)
+ rmSync(root, { recursive: true, force: true })
+ }
+ },
+ 25_000
+)
diff --git a/src/main/macos-update-running-instances.test.ts b/src/main/macos-update-running-instances.test.ts
new file mode 100644
index 00000000000..f6af6f04f53
--- /dev/null
+++ b/src/main/macos-update-running-instances.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ getMacUpdateRunningInstances,
+ parseMacUpdateRunningInstances
+} from './macos-update-running-instances'
+
+const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
+vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
+
+const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test'
+
+describe('macOS update running instances', () => {
+ beforeEach(() => {
+ runProcessMock.mockReset()
+ })
+
+ it('excludes the current process from the native registry result', () => {
+ expect(parseMacUpdateRunningInstances('[100,101,102]\n', 100)).toEqual([101, 102])
+ expect(parseMacUpdateRunningInstances('[]\n', 100)).toEqual([])
+ })
+
+ it.each([
+ 'not JSON',
+ '',
+ '{}',
+ 'null',
+ '[0]',
+ '[-1]',
+ '[1.5]',
+ '["101"]',
+ '[null]',
+ '[9007199254740992]'
+ ])('rejects invalid registry output: %s', (listing) => {
+ expect(() => parseMacUpdateRunningInstances(listing, 100)).toThrow()
+ })
+
+ it.runIf(process.platform === 'darwin')(
+ 'passes the bundle path as an argument rather than executable script',
+ async () => {
+ const unusualExecutable = '/Applications/Orca "Test" $HOME.app/Contents/MacOS/Orca Test'
+ runProcessMock.mockResolvedValue({ code: 0, stdout: '[]', timedOut: false })
+ await expect(getMacUpdateRunningInstances(unusualExecutable)).resolves.toEqual([])
+ expect(runProcessMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ args: [
+ '-l',
+ 'JavaScript',
+ '-e',
+ expect.not.stringContaining('$HOME'),
+ '/Applications/Orca "Test" $HOME.app'
+ ]
+ })
+ )
+ }
+ )
+
+ it('skips development runtimes without probing the host', async () => {
+ expect(await getMacUpdateRunningInstances('/usr/local/bin/node')).toEqual([])
+ expect(runProcessMock).not.toHaveBeenCalled()
+ })
+
+ it.each(['linux', 'win32'])('does not probe on %s', async (platform) => {
+ vi.stubGlobal('process', { ...process, platform })
+ try {
+ expect(await getMacUpdateRunningInstances(executable)).toEqual([])
+ expect(runProcessMock).not.toHaveBeenCalled()
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it.runIf(process.platform === 'darwin')(
+ 'uses the bounded native application registry query and preserves paths with spaces',
+ async () => {
+ runProcessMock.mockResolvedValue({
+ code: 0,
+ stdout: '[100,101]\n',
+ timedOut: false
+ })
+ expect(await getMacUpdateRunningInstances(executable, 100)).toEqual([101])
+ expect(runProcessMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ program: '/usr/bin/osascript',
+ args: [
+ '-l',
+ 'JavaScript',
+ '-e',
+ expect.stringContaining('runningApplicationsWithBundleIdentifier'),
+ '/Applications/Orca Test.app'
+ ],
+ timeoutMs: 5000,
+ killOnOutputLimit: true
+ })
+ )
+ }
+ )
+
+ it.runIf(process.platform === 'darwin').each([
+ { code: 1, timedOut: false },
+ { code: null, timedOut: true },
+ { code: 0, timedOut: false, outputTruncated: true }
+ ])('fails closed for incomplete query results: %j', async (result) => {
+ runProcessMock.mockResolvedValue({ stdout: '', ...result })
+ await expect(getMacUpdateRunningInstances(executable)).rejects.toThrow('Could not check')
+ })
+})
diff --git a/src/main/macos-update-running-instances.ts b/src/main/macos-update-running-instances.ts
new file mode 100644
index 00000000000..1ce757d30f5
--- /dev/null
+++ b/src/main/macos-update-running-instances.ts
@@ -0,0 +1,53 @@
+import path from 'node:path'
+import { runProcess } from '../shared/child-process/run-process'
+
+const RUNNING_INSTANCES_SCRIPT = `function run(argv) {
+ ObjC.import('AppKit');
+ const bundle = $.NSBundle.bundleWithPath(argv[0]);
+ const identifier = ObjC.unwrap(bundle.bundleIdentifier);
+ if (typeof identifier !== 'string' || !identifier) throw new Error('Missing bundle identifier');
+ const target = $.NSURL.fileURLWithPath(argv[0]).URLByStandardizingPath;
+ const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(identifier);
+ const pids = [];
+ for (let i = 0; i < apps.count; i++) {
+ const app = apps.objectAtIndex(i);
+ if (app.bundleURL && app.bundleURL.URLByStandardizingPath.isEqual(target)) {
+ pids.push(Number(app.processIdentifier));
+ }
+ }
+ return JSON.stringify(pids);
+}`
+
+/** Squirrel waits for every main application process from the target bundle. */
+export async function getMacUpdateRunningInstances(
+ executable = process.execPath,
+ currentPid = process.pid
+): Promise {
+ if (process.platform !== 'darwin' || !executable.includes('.app/Contents/MacOS/')) {
+ return []
+ }
+ const bundlePath = path.dirname(path.dirname(path.dirname(executable)))
+ // Match ShipIt's registry query; run-as-node workers share the executable but do not block it.
+ const result = await runProcess({
+ program: '/usr/bin/osascript',
+ args: ['-l', 'JavaScript', '-e', RUNNING_INSTANCES_SCRIPT, bundlePath],
+ timeoutMs: 5_000,
+ maxOutputBytes: 2 * 1024 * 1024,
+ killOnOutputLimit: true
+ })
+ if (result.code !== 0 || result.timedOut || result.outputTruncated) {
+ throw new Error('Could not check running Orca instances')
+ }
+ return parseMacUpdateRunningInstances(result.stdout, currentPid)
+}
+
+export function parseMacUpdateRunningInstances(listing: string, currentPid: number): number[] {
+ const pids: unknown = JSON.parse(listing)
+ if (
+ !Array.isArray(pids) ||
+ !pids.every((pid: unknown) => typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0)
+ ) {
+ throw new Error('Invalid macOS application listing')
+ }
+ return pids.filter((pid: number) => pid !== currentPid)
+}
diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
index ec3035ba634..3cba71460ef 100644
--- a/src/main/managed-data-accounts/profile-removal-recovery.test.ts
+++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
@@ -5,6 +5,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
+ readdirSync,
readFileSync,
rmSync,
symlinkSync,
@@ -63,6 +64,43 @@ async function finishStartup(service: ManagedDataAccountService) {
}
describe('interrupted managed account removal recovery', () => {
+ it.each(['devin', 'opencode'] as const)(
+ 'quarantines the canonical %s directory registered with an uppercase UUID',
+ async (provider) => {
+ const fixture = interruptedRemoval(provider)
+ const id = fixture.id.toUpperCase()
+ const registered = {
+ accounts: [{ ...fixture.before.accounts[0], id }],
+ activeAccountId: id
+ }
+ expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true)
+ const cleanup = vi.fn(() => {
+ throw new Error('cleanup deferred')
+ })
+ const service = new ManagedDataAccountService(storage, cleanup)
+
+ await expect(service.remove(provider, id)).resolves.toEqual({
+ accounts: [],
+ activeAccountId: null
+ })
+
+ const pendingRoot = join(fixture.providerRoot, '.pending-delete')
+ const pendingDirectory = join(pendingRoot, fixture.id)
+ expect(cleanup).toHaveBeenCalledWith(pendingDirectory)
+ expect(readdirSync(pendingRoot)).toEqual([fixture.id])
+ expect(existsSync(fixture.directory)).toBe(false)
+ expect(readFileSync(join(pendingDirectory, 'data', 'test-credentials'), 'utf8')).toBe(
+ 'private-test-only-credential'
+ )
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(pendingDirectory)).toBe(false)
+ expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null })
+ }
+ )
+
it.each(['devin', 'opencode'] as const)(
'quarantines a committed %s removal left before quarantine on restart',
async (provider) => {
@@ -77,39 +115,48 @@ describe('interrupted managed account removal recovery', () => {
}
)
- it('recovers after both quarantine and metadata rollback fail without the removed id', async () => {
- const source = join(root, 'source')
- mkdirSync(join(source, 'devin'), { recursive: true })
- writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"')
- const service = new ManagedDataAccountService(storage)
- const before = await service.add('devin', source, 'Work')
- const id = before.accounts[0].id
- const directory = join(storage, 'devin', id)
- const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml')
- const metadataPath = join(storage, 'devin', 'accounts.json')
- const rollbackPath = `${metadataPath}.${id}.rollback`
- const credentials = readFileSync(credentialsPath)
- const original = readFileSync(metadataPath)
- const rename = fileSystem.renameSync
- const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
- if (from === directory || from === rollbackPath) {
- throw new Error('injected removal and rollback lock')
+ it.each(['lowercase', 'uppercase'] as const)(
+ 'recovers after quarantine and rollback fail for a %s registered UUID',
+ async (spelling) => {
+ const source = join(root, 'source')
+ mkdirSync(join(source, 'devin'), { recursive: true })
+ writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"')
+ const service = new ManagedDataAccountService(storage)
+ const added = await service.add('devin', source, 'Work')
+ const directoryId = added.accounts[0].id
+ const id = spelling === 'uppercase' ? directoryId.toUpperCase() : directoryId
+ const before = {
+ accounts: [{ ...added.accounts[0], id }],
+ activeAccountId: id
}
- return rename(from, to)
- })
- await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError)
- expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
- expect(readFileSync(credentialsPath)).toEqual(credentials)
- expect(readFileSync(rollbackPath)).toEqual(original)
- failingRename.mockRestore()
+ const directory = join(storage, 'devin', directoryId)
+ const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml')
+ const metadataPath = join(storage, 'devin', 'accounts.json')
+ const rollbackPath = `${metadataPath}.${directoryId}.rollback`
+ expect(writeSecureFile(metadataPath, JSON.stringify(before))).toBe(true)
+ const credentials = readFileSync(credentialsPath)
+ const original = readFileSync(metadataPath)
+ const rename = fileSystem.renameSync
+ const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
+ if (from === directory || from === rollbackPath) {
+ throw new Error('injected removal and rollback lock')
+ }
+ return rename(from, to)
+ })
+ await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError)
+ expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readFileSync(credentialsPath)).toEqual(credentials)
+ expect(readFileSync(rollbackPath)).toEqual(original)
+ failingRename.mockRestore()
- const restarted = new ManagedDataAccountService(storage)
- await finishStartup(restarted)
- expect(existsSync(directory)).toBe(false)
- expect(existsSync(rollbackPath)).toBe(false)
- expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
- expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only')
- })
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(directory)).toBe(false)
+ expect(existsSync(rollbackPath)).toBe(false)
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only')
+ }
+ )
it('retains recovery evidence when quarantine is locked and continues other removals', async () => {
const locked = interruptedRemoval()
diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts
index 9cd83379054..46f6ea1c312 100644
--- a/src/main/managed-data-accounts/profile-removal.ts
+++ b/src/main/managed-data-accounts/profile-removal.ts
@@ -27,10 +27,11 @@ export class ManagedDataAccountProfileRemoval {
if (!z.uuid().safeParse(accountId).success) {
throw new Error('Managed account not found.')
}
- const directory = join(this.root, provider, accountId)
- const pendingDirectory = join(this.root, provider, '.pending-delete', accountId)
+ const pathId = accountId.toLowerCase()
+ const directory = join(this.root, provider, pathId)
+ const pendingDirectory = join(this.root, provider, '.pending-delete', pathId)
const metadataPath = join(this.root, provider, 'accounts.json')
- const rollbackPath = `${metadataPath}.${accountId}.rollback`
+ const rollbackPath = `${metadataPath}.${pathId}.rollback`
if (!state.accounts.some((account) => account.id === accountId)) {
if (state.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase())) {
throw new Error('Managed account not found.')
diff --git a/src/main/managed-data-accounts/service-enrollment-persistence.test.ts b/src/main/managed-data-accounts/service-enrollment-persistence.test.ts
new file mode 100644
index 00000000000..9375071d2f1
--- /dev/null
+++ b/src/main/managed-data-accounts/service-enrollment-persistence.test.ts
@@ -0,0 +1,209 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import * as filesystem from 'node:fs'
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import SyncDatabase from '../sqlite/sync-database'
+import * as secureFile from '../../shared/secure-file'
+import { ManagedDataAccountService } from './service'
+
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual }
+})
+
+let root: string
+let source: string
+let service: ManagedDataAccountService
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'orca-enrollment-persistence-'))
+ source = join(root, 'source')
+ mkdirSync(join(source, 'devin'), { recursive: true })
+ writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only-key"\n')
+ mkdirSync(join(source, 'opencode'), { recursive: true })
+ const database = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
+ database.exec(
+ 'CREATE TABLE session_v2 (id TEXT); CREATE TABLE credential (integration_id TEXT, value TEXT)'
+ )
+ database
+ .prepare('INSERT INTO credential VALUES (?, ?)')
+ .run('google', '{"type":"key","key":"test-only-key"}')
+ database.close()
+ service = new ManagedDataAccountService(join(root, 'managed'))
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ rmSync(root, { recursive: true, force: true })
+})
+
+describe.each(['opencode', 'devin'] as const)('committed %s enrollment', (provider) => {
+ it.each(['after write', 'unrestricted'])(
+ 'keeps captured credentials when metadata persistence reports %s failure',
+ async (failure) => {
+ const metadata = join(root, 'managed', provider, 'accounts.json')
+ const write = secureFile.writeSecureFile
+ vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ const result = write(...args)
+ if (args[0] !== metadata) {
+ return result
+ }
+ if (failure === 'unrestricted') {
+ return false
+ }
+ throw new Error('post-publication write failure')
+ })
+ await expect(service.add(provider, source, 'Work')).rejects.toThrow(
+ failure === 'unrestricted' ? 'metadata permissions' : 'post-publication'
+ )
+ const state = service.list(provider)
+ expect(state.accounts).toHaveLength(1)
+ expect(state.activeAccountId).toBe(state.accounts[0].id)
+ const directory = join(root, 'managed', provider, state.accounts[0].id)
+ expect(existsSync(directory)).toBe(true)
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(join(directory, 'data'))
+ }
+ )
+
+ it('cleans an unregistered profile when metadata fails before publication', async () => {
+ const metadata = join(root, 'managed', provider, 'accounts.json')
+ const write = secureFile.writeSecureFile
+ vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ if (args[0] === metadata) {
+ throw new Error('pre-publication write failure')
+ }
+ return write(...args)
+ })
+ await expect(service.add(provider, source, 'Work')).rejects.toThrow('pre-publication')
+ expect(service.list(provider)).toEqual({ accounts: [], activeAccountId: null })
+ expect(readdirSync(join(root, 'managed', provider))).toEqual([])
+ })
+
+ it('keeps credentials when published metadata is unreadable', async () => {
+ const metadata = join(root, 'managed', provider, 'accounts.json')
+ const write = secureFile.writeSecureFile
+ let published = ''
+ vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ const result = write(...args)
+ if (args[0] !== metadata) {
+ return result
+ }
+ published = readFileSync(metadata, 'utf8')
+ writeFileSync(metadata, '{')
+ throw new Error('post-publication metadata unreadable')
+ })
+ await expect(service.add(provider, source, 'Work')).rejects.toThrow('metadata unreadable')
+ expect(
+ readdirSync(join(root, 'managed', provider)).filter((name) => name !== 'accounts.json')
+ ).toHaveLength(1)
+ writeFileSync(metadata, published)
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy()
+ })
+
+ it('keeps credentials when metadata existence cannot be checked', async () => {
+ const metadata = join(root, 'managed', provider, 'accounts.json')
+ const write = secureFile.writeSecureFile
+ const exists = filesystem.existsSync
+ const stat = filesystem.lstatSync
+ let inaccessible = false
+ vi.spyOn(filesystem, 'existsSync').mockImplementation((path) =>
+ inaccessible && path === metadata ? false : exists(path)
+ )
+ vi.spyOn(filesystem, 'lstatSync').mockImplementation((...args) => {
+ if (inaccessible && args[0] === metadata) {
+ throw Object.assign(new Error('Metadata access denied'), { code: 'EACCES' })
+ }
+ return stat(...args)
+ })
+ vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ const result = write(...args)
+ if (args[0] !== metadata) {
+ return result
+ }
+ inaccessible = true
+ return false
+ })
+ await expect(service.add(provider, source, 'Work')).rejects.toThrow('metadata permissions')
+ inaccessible = false
+ const state = service.list(provider)
+ expect(exists(join(root, 'managed', provider, state.accounts[0].id))).toBe(true)
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy()
+ })
+
+ it('preserves a profile registered with another UUID case', async () => {
+ const metadata = join(root, 'managed', provider, 'accounts.json')
+ const write = secureFile.writeSecureFile
+ const writer = vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ const result = write(...args)
+ if (args[0] !== metadata) {
+ return result
+ }
+ const state = service.list(provider)
+ writeFileSync(
+ metadata,
+ JSON.stringify({
+ ...state,
+ accounts: state.accounts.map((account) => ({ ...account, id: account.id.toUpperCase() })),
+ activeAccountId: state.activeAccountId?.toUpperCase()
+ })
+ )
+ throw new Error('post-publication UUID case change')
+ })
+ await expect(service.add(provider, source, 'Work')).rejects.toThrow('UUID case change')
+ writer.mockRestore()
+ const registered = service.list(provider).accounts[0]
+ const dataHome = join(root, 'managed', provider, registered.id.toLowerCase(), 'data')
+ expect(existsSync(dataHome)).toBe(true)
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome)
+ expect(
+ service.transcriptEnvironments(provider).map((environment) => environment.XDG_DATA_HOME)
+ ).toEqual([dataHome])
+ expect((await service.select(provider, registered.id.toLowerCase())).activeAccountId).toBe(
+ registered.id
+ )
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome)
+ expect((await service.select(provider, registered.id.toUpperCase())).activeAccountId).toBe(
+ registered.id
+ )
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome)
+ })
+
+ it('selects the registered UUID spelling and searches its transcript first', async () => {
+ const personal = (await service.add(provider, source, 'Personal')).accounts[0]
+ const work = (await service.add(provider, source, 'Work')).accounts[1]
+ const selected = await service.select(provider, work.id.toUpperCase())
+ expect(selected.activeAccountId).toBe(work.id)
+ expect(service.list(provider).activeAccountId).toBe(work.id)
+ expect(
+ service.transcriptEnvironments(provider).map((environment) => environment.XDG_DATA_HOME)
+ ).toEqual(
+ [work, personal].map((account) =>
+ join(root, 'managed', provider, account.id.toLowerCase(), 'data')
+ )
+ )
+ })
+
+ it('isolates a throwing listener after enrollment has committed', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ service.onChanged(() => {
+ throw new Error('private-listener-detail')
+ })
+ const healthy = vi.fn()
+ service.onChanged(healthy)
+ const state = await service.add(provider, source, 'Work')
+ expect(service.list(provider)).toEqual(state)
+ expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy()
+ expect(healthy).toHaveBeenCalledOnce()
+ expect(warn).toHaveBeenCalledOnce()
+ expect(warn.mock.calls.flat().join(' ')).not.toContain('private-listener-detail')
+ })
+})
diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts
index 5ab7f12d04c..bc96800a5aa 100644
--- a/src/main/managed-data-accounts/service.test.ts
+++ b/src/main/managed-data-accounts/service.test.ts
@@ -491,15 +491,18 @@ describe('managed data accounts', () => {
expect(service.transcriptEnvironments('devin')).toEqual([secondEnvironment])
})
- it('rejects a credential symlink without touching its target', async () => {
- const original = join(source, 'devin', 'credentials.toml')
- const target = join(root, 'private.toml')
- writeFileSync(target, readFileSync(original))
- rmSync(original)
- symlinkSync(target, original)
- await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file')
- expect(readFileSync(target, 'utf8')).toContain('test-only-key')
- })
+ it.skipIf(process.platform === 'win32')(
+ 'rejects a credential symlink without touching its target',
+ async () => {
+ const original = join(source, 'devin', 'credentials.toml')
+ const target = join(root, 'private.toml')
+ writeFileSync(target, readFileSync(original))
+ rmSync(original)
+ symlinkSync(target, original)
+ await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file')
+ expect(readFileSync(target, 'utf8')).toContain('test-only-key')
+ }
+ )
it('keeps credential parse errors out of RPC messages', async () => {
writeFileSync(
diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts
index 5ebf2ae91f2..749fb023876 100644
--- a/src/main/managed-data-accounts/service.ts
+++ b/src/main/managed-data-accounts/service.ts
@@ -69,8 +69,7 @@ export class ManagedDataAccountService {
if (!existsSync(path)) {
return { accounts: [], activeAccountId: null }
}
- this.assertOwned(path)
- return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8')))
+ return this.readState(path)
}
add(
@@ -98,7 +97,24 @@ export class ManagedDataAccountService {
activeAccountId: id
})
} catch (error) {
- rmSync(directory, { recursive: true, force: true })
+ let registered = true
+ try {
+ registered = this.readState(join(this.root, provider, 'accounts.json')).accounts.some(
+ (account) => account.id.toLowerCase() === id.toLowerCase()
+ )
+ } catch (metadataError) {
+ if (
+ metadataError instanceof Error &&
+ 'code' in metadataError &&
+ metadataError.code === 'ENOENT'
+ ) {
+ registered = false
+ }
+ // Unreadable metadata cannot prove that this profile is unregistered.
+ }
+ if (!registered) {
+ rmSync(directory, { recursive: true, force: true })
+ }
throw error
}
})
@@ -110,10 +126,9 @@ export class ManagedDataAccountService {
): Promise {
return this.mutate(async () => {
const state = this.list(provider)
- if (accountId !== null) {
- this.requireAccount(provider, accountId)
- }
- return this.persist(provider, { ...state, activeAccountId: accountId })
+ const activeAccountId =
+ accountId === null ? null : this.requireAccount(provider, accountId).id
+ return this.persist(provider, { ...state, activeAccountId })
})
}
@@ -206,7 +221,7 @@ export class ManagedDataAccountService {
provider: ManagedDataAccountProvider,
accountId: string
): Record {
- const directory = this.requireAccount(provider, accountId)
+ const { directory } = this.requireAccount(provider, accountId)
return {
XDG_DATA_HOME: join(directory, 'data'),
XDG_STATE_HOME: join(directory, 'state'),
@@ -219,13 +234,19 @@ export class ManagedDataAccountService {
return () => this.listeners.delete(listener)
}
- private requireAccount(provider: ManagedDataAccountProvider, id: string): string {
- if (!this.list(provider).accounts.some((account) => account.id === id)) {
+ private requireAccount(
+ provider: ManagedDataAccountProvider,
+ id: string
+ ): { id: string; directory: string } {
+ const account = this.list(provider).accounts.find(
+ (registered) => registered.id.toLowerCase() === id.toLowerCase()
+ )
+ if (!account) {
throw new Error('Managed account not found.')
}
- const directory = join(this.root, provider, id)
+ const directory = join(this.root, provider, account.id.toLowerCase())
this.assertOwned(directory)
- return directory
+ return { id: account.id, directory }
}
private persist(
@@ -237,6 +258,11 @@ export class ManagedDataAccountService {
return checked
}
+ private readState(path: string): ManagedDataAccountsState {
+ this.assertOwned(path)
+ return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8')))
+ }
+
private writeState(
provider: ManagedDataAccountProvider,
state: ManagedDataAccountsState
@@ -254,7 +280,11 @@ export class ManagedDataAccountService {
private notifyChanged(): void {
for (const listener of this.listeners) {
- listener()
+ try {
+ listener()
+ } catch {
+ console.warn('[managed-data-accounts] Account change listener failed.')
+ }
}
}
diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
index a66d43697f6..0828740ca6f 100644
--- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
+++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
@@ -13,7 +13,7 @@ import {
import type { JournalHostDatabase } from './journal-host-database'
import type { JournalReducerState } from './journal-reducer'
import type { JournalRow } from './journal-row-schema'
-import type { JournalRowTransactionHook } from './journal-row-writer'
+import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer'
import type { JournalSubmissionConsume } from './journal-store-contracts'
import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds'
import {
@@ -101,14 +101,18 @@ export class JournalQueuedMessages {
return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp)
}
- /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */
- insert(input: {
- messageId: string
- body: AgentJournalMessageItem
- fingerprint: string
- hostInstance: string
- carriedFrom?: string
- }): Promise {
+ /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused.
+ * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */
+ insert(
+ input: {
+ messageId: string
+ body: AgentJournalMessageItem
+ fingerprint: string
+ hostInstance: string
+ carriedFrom?: string
+ },
+ receipt?: JournalOperationReceipt
+ ): Promise {
const { sessionId } = this.deps
let inserted = false
return this.transact(
@@ -121,14 +125,17 @@ export class JournalQueuedMessages {
}
inserted = true
const { epoch, lastSequence } = this.deps.state()
- return insertQueuedMessage(db, {
+ const row = insertQueuedMessage(db, {
...input,
sessionId,
queuedAt: { epoch, sequence: lastSequence },
now: this.deps.now()
})
+ receipt?.write(db)
+ return row
},
- () => inserted
+ () => inserted,
+ receipt?.committed
)
}
@@ -206,15 +213,17 @@ export class JournalQueuedMessages {
}
/** One standalone draft-table transaction on the journal's queue; one that
- * changed rows bumps the revision and notifies after COMMIT. */
+ * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */
private transact(
run: (db: Database.Database) => JournalWriteResult,
- changed: (result: T) => boolean
+ changed: (result: T) => boolean,
+ adopted?: () => void
): Promise {
return this.deps.serialize(() => {
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
const result = this.deps.database().transaction(run)
if (changed(result)) {
+ adopted?.()
this.changeRevision++
this.deps.committed()
}
diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts
index b07fda9459b..ca274120a7b 100644
--- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts
+++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts
@@ -11,6 +11,14 @@ import type { JournalWriteBody } from './journal-write-queue'
* nothing can interleave inside the transaction. */
export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void
+/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs
+ * inside that transaction on the same connection, `committed` synchronously right after its
+ * COMMIT and never after a rollback. */
+export type JournalOperationReceipt = {
+ write: (db: Database.Database) => void
+ committed: () => void
+}
+
export type JournalRowWriterDeps = {
sessionId: string
now: () => number
@@ -35,7 +43,8 @@ export class JournalRowWriter {
enqueue(
build: (seq: number, ts: number) => JournalRow,
- hook?: JournalRowTransactionHook
+ hook?: JournalRowTransactionHook,
+ receipt?: JournalOperationReceipt
): Promise {
return this.deps.serialize(() => {
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
@@ -46,6 +55,7 @@ export class JournalRowWriter {
this.deps.database().transaction((db) => {
insertJournalRow(db, this.deps.sessionId, row)
hook?.(db, row)
+ receipt?.write(db)
this.runBookkeeping(db, row)
})
} catch (error) {
@@ -54,7 +64,8 @@ export class JournalRowWriter {
}
// COMMIT landed, so the row is durable: adopt it before anything that can
// fail. Rejecting here instead would leave the next append reusing a
- // sequence the table already holds.
+ // sequence the table already holds. The ledger first: it cannot throw, the fold can.
+ receipt?.committed()
this.deps.commit(row)
return row
})
@@ -64,9 +75,13 @@ export class JournalRowWriter {
* replay uses — all inside one serialized step — answering where the row landed. */
append(
build: (seq: number, ts: number) => JournalRow,
- hook?: JournalRowTransactionHook
+ hook?: JournalRowTransactionHook,
+ receipt?: JournalOperationReceipt
): Promise {
- return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq }))
+ return this.enqueue(build, hook, receipt).then((row) => ({
+ epoch: row.epoch,
+ sequence: row.seq
+ }))
}
private runBookkeeping(db: Database.Database, row: JournalRow): void {
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index 5a4880155de..2349524ccfe 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -63,7 +63,7 @@ import {
journalStopEventRowBuilder
} from './journal-stop-and-resume-rows'
import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema'
-import type { JournalRowWriter } from './journal-row-writer'
+import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer'
import type { JournalEpochController } from './journal-epoch-controller'
import { JournalWriteQueue } from './journal-write-queue'
import { createJournalStoreCollaborators } from './journal-store-collaborators'
@@ -340,11 +340,14 @@ export class AgentSessionJournal {
input: JournalSubmissionInput,
/** Present: this submission is a queued draft's conversion, and the draft's
* state transition commits in the SAME transaction — exactly-once consume. */
- consume?: JournalSubmissionConsume
+ consume?: JournalSubmissionConsume,
+ /** The send's ledger answer, committed with this row. */
+ receipt?: JournalOperationReceipt
): Promise {
return this.rowWriter.append(
journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume),
- consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume)
+ consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume),
+ receipt
)
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts
index ddef0ed0bb6..9c920b45eb3 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts
@@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec
import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
+import { createStoppedClaudeDeadline } from './structured-agent-session-stop-deadline.test-fixture'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
import {
HOST_TEST_NOW as NOW,
@@ -133,6 +134,8 @@ beforeEach(async () => {
})
afterEach(async () => {
+ vi.restoreAllMocks()
+ vi.useRealTimers()
await adapter.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
@@ -214,6 +217,18 @@ function stop(turnId?: string) {
return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields })
}
+/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */
+const laneDrained = (): Promise => host['tasks'].serialize(SESSION, async () => {})
+
+const stopAcrossGrace = createStoppedClaudeDeadline({
+ host: () => host,
+ adapter: () => adapter,
+ claude: () => claude,
+ sessionId: SESSION,
+ stop,
+ laneDrained
+})
+
/** How many person's Stop events the journal holds when the child's close begins. */
function stopEventsAtClose(connection: FakeConnection): () => number | undefined {
let atClose: number | undefined
@@ -231,11 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined
return () => atClose
}
-/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */
-function laneDrained(): Promise {
- return host['tasks'].serialize(SESSION, async () => {})
-}
-
function wrote(connection: FakeConnection, text: string): boolean {
return connection.sent.some((message) => JSON.stringify(message).includes(text))
}
@@ -369,14 +379,15 @@ it('ends the child once the grace runs out when Claude says nothing after a Stop
claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] })
const clientMessageId = await sendUnechoed(connection)
- const asked = Date.now()
- await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
+ await expect(stopAcrossGrace(connection, 'request-end')).resolves.toMatchObject({
+ ok: true,
+ value: { cancelled: true }
+ })
await laneDrained()
// As before the wait: the send Claude never answered is doubt once its child ends.
expect(connection.closed).toBe(true)
expect(eventsAtClose()).toBe(1)
- expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500)
expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' })
}, 15_000)
@@ -447,13 +458,14 @@ it('ends the child within the grace when Claude never answers the interrupt', as
const eventsAtClose = stopEventsAtClose(connection)
await openTurn(connection)
- const asked = Date.now()
- await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
+ await expect(stopAcrossGrace(connection, 'interrupt')).resolves.toMatchObject({
+ ok: true,
+ value: { cancelled: true }
+ })
await laneDrained()
expect(connection.closed).toBe(true)
expect(eventsAtClose()).toBe(1)
- expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500)
expect(await turnOutcome()).toBe('cancellation')
expect(await statusTexts()).toEqual(['Cancellation requested.'])
}, 15_000)
@@ -672,7 +684,15 @@ it.each([
await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true))
// As the phone sends it: the turn it last saw working.
- await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
+ await expect(
+ _answer === 'fails'
+ ? stop(ended)
+ : stopAcrossGrace(
+ connection,
+ interrupt === NEVER_ANSWERS ? 'interrupt' : 'request-end',
+ ended
+ )
+ ).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
await laneDrained()
expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
index 3fbb96ddaf8..1133342270d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
@@ -61,7 +61,8 @@ export function sendStructuredAgentSessionTurn(
* prompt never set it. */
userSend?: true
beforeRun?: () => void
- }
+ },
+ arrival?: Parameters[2]
): Promise> {
const plan = sendPlan(params)
return mutateStructuredAgentSession(
@@ -80,7 +81,7 @@ export function sendStructuredAgentSessionTurn(
(await plan.run(ctx))
)
},
- sendPreparation(context, params.envelope)
+ sendPreparation(context, params.envelope, arrival)
)
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts
index 5bb072f2594..3ed04e1f2fe 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts
@@ -4,16 +4,24 @@
// own admission rules by sitting next to the call site.
//
// Admission is two-phase for a call that brings a `prepareSession`. The ledger's
-// answer comes first and places nothing; a call it will admit may then give the
-// session an owner, and only after that are the row placed and the lease
-// checked — against the lease as it stands once the owner is there.
+// answer comes first and places nothing. An id it refuses is answered then, with
+// nothing opened; so is a recorded id that settled refused. Any other recorded id
+// is answered after the plan's own preparation for a replay (a send's only opens
+// the conversation), from the journal, with no admit write. A call the ledger
+// admits, or a replay that proves nothing landed, may then give the session an
+// owner, and only after that are the row placed and the lease checked — against
+// the lease as it stands once the owner is there.
import {
admitAgentSessionMutation,
agentSessionFingerprintConflict,
+ agentSessionLedgerRefusal,
computeAgentSessionPayloadFingerprint
} from '../../../shared/agent-session-mutation-envelope'
-import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger'
+import type {
+ AgentSessionOperationDecision,
+ AgentSessionOperationRow
+} from '../../../shared/agent-session-operation-ledger'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
refuse,
@@ -36,7 +44,10 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement'
-import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
+import {
+ agentSessionOperationOutcomeUnknown,
+ resolveAgentSessionReplayOutcome
+} from './structured-agent-session-replay-outcome'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
@@ -103,12 +114,27 @@ export async function admitAndRunAgentSessionMutation(
if (!ledger) {
return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED)
}
- if (ledger.decision.decision !== 'refused') {
- const prepared = await request.prepareSession(ledger.decision.decision, ledger.record)
- if (!prepared.ok) {
- return prepared
+ if (ledger.decision.decision === 'refused') {
+ // Nothing to read, prepare or write: a closed chat or a store that takes no write answers alike.
+ return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision))
+ }
+ if (ledger.decision.decision === 'replay') {
+ const answered = await answerRecordedOperation(
+ request,
+ request.prepareSession,
+ ledger.decision.row,
+ ledger.record,
+ hostFingerprint
+ )
+ if (answered !== 'rerun') {
+ return answered
}
}
+ const record = request.store.getRecord(envelope.sessionId) ?? ledger.record
+ const prepared = await request.prepareSession('admit', record)
+ if (!prepared.ok) {
+ return prepared
+ }
}
const journal = request.journal()
if (!journal) {
@@ -151,18 +177,9 @@ export async function admitAndRunAgentSessionMutation(
const fence = record.lease.runtimeFence
const context = turnContext(request, journal, fence)
if (admission.decision === 'replay') {
- const replay = resolveAgentSessionReplayOutcome({
- operationId: envelope.clientOperationId,
- outcome: admission.row.outcome,
- reconstruct: () => plan.replay(context, admission.row.outcome),
- rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context),
- recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState
- })
- if (replay.decision === 'refuse') {
- return refuseAgentSessionMutation(replay.refusal)
- }
- if (replay.decision === 'replay') {
- return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value }
+ const replayed = replayRecordedOperation(request, context, admission.row)
+ if (replayed !== 'rerun') {
+ return replayed
}
// Nothing durable landed, so this id is about to run for the first time. A
// refused call leaves its ledger row behind, and replaying past the lease
@@ -197,6 +214,85 @@ export async function admitAndRunAgentSessionMutation(
: refuseAgentSessionMutation(outcome.refusal)
}
+/**
+ * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with
+ * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation
+ * its run wrote to. `rerun` when nothing durable landed, so the call runs as a first run.
+ */
+async function answerRecordedOperation(
+ request: AgentSessionMutationRequest,
+ prepareSession: NonNullable['prepareSession']>,
+ row: AgentSessionOperationRow,
+ record: AgentSessionRecord,
+ hostFingerprint: string
+): Promise | 'rerun'> {
+ const { plan, envelope } = request
+ if (row.outcome.status === 'failed') {
+ const replay = resolveAgentSessionReplayOutcome({
+ operationId: envelope.clientOperationId,
+ outcome: row.outcome,
+ reconstruct: () => null
+ })
+ if (replay.decision === 'refuse') {
+ return refuseAgentSessionMutation(replay.refusal)
+ }
+ }
+ const prepared = await prepareSession('replay', record)
+ if (!prepared.ok) {
+ return prepared
+ }
+ const journal = request.journal()
+ // Read again after the open: the row as it stands, against the record the open left.
+ const current = request.store.evaluateMutationOperation({
+ callerKey: request.callerKey,
+ envelope,
+ hostFingerprint,
+ now: request.now(),
+ ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {})
+ })
+ if (!journal || !current) {
+ return refuseAgentSessionMutation(
+ agentSessionOperationOutcomeUnknown(envelope.clientOperationId)
+ )
+ }
+ if (current.decision.decision === 'refused') {
+ return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, current.decision))
+ }
+ if (current.decision.decision === 'admit') {
+ // The row is gone since: the first-run path decides it from scratch.
+ return 'rerun'
+ }
+ const context = turnContext(request, journal, current.record.lease.runtimeFence)
+ return replayRecordedOperation(request, context, current.decision.row)
+}
+
+/** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */
+function replayRecordedOperation(
+ { plan, envelope }: AgentSessionMutationRequest,
+ context: AgentSessionTurnContext,
+ row: AgentSessionOperationRow
+): AgentSessionMutationResult | 'rerun' {
+ const replay = resolveAgentSessionReplayOutcome({
+ operationId: envelope.clientOperationId,
+ outcome: row.outcome,
+ reconstruct: () => plan.replay(context, row.outcome),
+ rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context),
+ recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState
+ })
+ if (replay.decision === 'refuse') {
+ return refuseAgentSessionMutation(replay.refusal)
+ }
+ return replay.decision === 'replay'
+ ? {
+ ok: true,
+ replayed: true,
+ fence: context.fence,
+ cursor: context.journal.cursor(),
+ value: replay.value
+ }
+ : 'rerun'
+}
+
/** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */
function admitWithoutLedgerRow(
{ store, logger }: Pick, 'store' | 'logger'>,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
index a81ea5270c8..6a2a6268ae9 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
@@ -3,7 +3,9 @@
//
// The replay half matters more than it looks. The ledger records only that an
// operation happened, so the durable answer usually comes back out of the
-// journal. Send is fail-closed: admission alone cannot prove non-delivery.
+// journal. Send is fail-closed: admission alone cannot prove non-delivery, so
+// its success commits with the row that accepts it, and a row still pending is
+// one that wrote nothing.
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
@@ -51,13 +53,23 @@ export type MutationPlan = {
conversationWrite?: true
/** Still runs, decided from the committed ledger, when its ledger row cannot be written. */
runsWithoutLedgerRow?: true
- markUnknownBeforeRun?: boolean
run: (ctx: AgentSessionTurnContext) => Promise>
replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null
rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean
recoverUnknownFromDurableState?: boolean
- settledOutcome?: (value: TValue) => AgentSessionOperationOutcome
-}
+} & (
+ | {
+ /** Its success is the row its run writes, so it commits in that row's transaction
+ * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That
+ * success is fixed before the value exists, so it records no `settledOutcome`. */
+ settlesWithWrite: true
+ settledOutcome?: never
+ }
+ | {
+ settlesWithWrite?: never
+ settledOutcome?: (value: TValue) => AgentSessionOperationOutcome
+ }
+)
export function sendPlan(params: {
envelope: AgentSessionMutationEnvelope
@@ -74,7 +86,7 @@ export function sendPlan(params: {
method: 'agentSession.send',
operationIdScope: 'global',
conversationWrite: true,
- markUnknownBeforeRun: true,
+ settlesWithWrite: true,
// `delivery` joins the OPERATION fingerprint only; the submission row keeps
// the body-only fingerprint the reducer's echo-aliasing recomputes.
fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) },
@@ -104,7 +116,9 @@ export function sendPlan(params: {
if (submission) {
return { clientMessageId, submission }
}
- if (outcome.status === 'failed') {
+ // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted,
+ // then a new epoch dropped its row. Unknown: only builds before this one wrote that.
+ if (outcome.status === 'failed' || outcome.status === 'pending') {
return null
}
const resolvedAt = ctx.now()
@@ -141,7 +155,7 @@ export function conversationCommandPlan(params: {
return {
method: 'agentSession.conversationCommand',
conversationWrite: true,
- markUnknownBeforeRun: true,
+ settlesWithWrite: true,
fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND },
recoverUnknownFromDurableState: true,
run: async (ctx) => {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts
index 203e31f4ea0..c7901edc1c1 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts
@@ -42,27 +42,18 @@ async function context(): Promise {
}
}
-/** Longer than any bookkeeping bound: a refusal must already be answered by then. */
-const SETTLED_WAIT_MS = 2_000
-
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
-it('returns a pre-dispatch refusal without waiting on redundant uncertainty persistence', async () => {
+it('rethrows a throw from a plan answered by its write, writing nothing and leaving the row pending', async () => {
const ctx = await context()
const { store } = hostTestState()
- const stalled = Promise.withResolvers()
- const refusing = Promise.withResolvers()
- const writes = vi
- .spyOn(store, 'recordOperationOutcome')
- .mockResolvedValueOnce()
- .mockImplementation(() => stalled.promise)
+ const writes = vi.spyOn(store, 'recordOperationOutcome')
const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded')
- let returned = false
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
- const result = runSettledAgentSessionMutation({
+ const result = await runSettledAgentSessionMutation({
store,
operationCallerKey: 'test',
envelope: envelope('agentSession.send', {}),
@@ -70,42 +61,30 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers
plan: {
method: 'agentSession.send',
fields: {},
- markUnknownBeforeRun: true,
+ settlesWithWrite: true,
run: async () => {
- refusing.resolve()
throw refusal
},
replay: () => null
}
- }).catch((error: unknown) => {
- returned = true
- return error
- })
- try {
- await refusing.promise
- await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS)
- expect(returned).toBe(true)
- expect(writes).toHaveBeenCalledOnce()
- expect(hostTestState().dispatch).not.toHaveBeenCalled()
- expect(vi.getTimerCount()).toBe(0)
- } finally {
- stalled.resolve()
- expect(await result).toBe(refusal)
- }
+ }).catch((error: unknown) => error)
+ expect(result).toBe(refusal)
+ expect(writes).not.toHaveBeenCalled()
+ expect(hostTestState().dispatch).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
})
-it.each([1, 2])(
- 'preserves a proven refusal through %s failed bookkeeping writes',
- async (failures) => {
+it.each([
+ { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 },
+ { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 }
+])(
+ 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes',
+ async ({ settlesWithWrite, failures }) => {
const ctx = await context()
const { store, dispatch } = hostTestState()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
- let writes = 0
- vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => {
- writes += 1
- if (writes > 1 && writes <= failures + 1) {
- throw new Error('private unbounded disk detail')
- }
+ const writes = vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => {
+ throw new Error('private unbounded disk detail')
})
const refusal = {
ok: false as const,
@@ -120,12 +99,13 @@ it.each([1, 2])(
plan: {
method: 'agentSession.send',
fields: {},
- markUnknownBeforeRun: true,
+ ...(settlesWithWrite ? { settlesWithWrite } : {}),
run,
replay: () => null
}
})
expect(result).toEqual(refusal)
+ expect(writes).toHaveBeenCalledTimes(failures)
expect(run).toHaveBeenCalledOnce()
expect(dispatch).not.toHaveBeenCalled()
expect(JSON.stringify(warning.mock.calls)).not.toContain('private unbounded disk detail')
@@ -160,7 +140,7 @@ it('accepts without touching the provider', async () => {
it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => {
const ctx = await context()
const { store } = hostTestState()
- vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue()
+ const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue()
const beforeRun = vi.fn(() => {
throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded')
})
@@ -175,6 +155,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing',
plan: sendPlan({ envelope: operation, body, beforeRun })
}).catch((error: unknown) => error)
expect(result).toBeInstanceOf(AgentSessionPreDispatchError)
+ expect(writes).not.toHaveBeenCalled()
expect(ctx.journal.submissions()).toEqual([])
expect(hostTestState().dispatch).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts
index 24de6ff708b..175afeaec35 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts
@@ -1,4 +1,5 @@
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
+import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
@@ -18,20 +19,35 @@ export async function runSettledAgentSessionMutation(input: {
plan: MutationPlan
context: AgentSessionTurnContext
}): Promise> {
+ const operation = {
+ callerKey: input.operationCallerKey,
+ operationId: input.envelope.clientOperationId
+ }
const settle = (
outcome: Parameters[0]['outcome']
- ) =>
- input.store.recordOperationOutcome({
- callerKey: input.operationCallerKey,
- operationId: input.envelope.clientOperationId,
- outcome
- })
+ ) => input.store.recordOperationOutcome({ ...operation, outcome })
+ const receipt = input.plan.settlesWithWrite
+ ? observedReceipt(
+ input.store.operationOutcomeReceipt({
+ ...operation,
+ outcome: { status: 'succeeded', sessionId: input.envelope.sessionId }
+ })
+ )
+ : undefined
+ const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context
let outcome: TurnOutcome | undefined
try {
- if (input.plan.markUnknownBeforeRun) {
- await settle({ status: 'unknown' })
+ const ran = await input.plan.run(context)
+ if (receipt?.wasCommitted() && !ran.ok) {
+ // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault.
+ throw new Error(
+ `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}`
+ )
}
- outcome = await input.plan.run(input.context)
+ if (receipt?.wasCommitted()) {
+ return ran
+ }
+ outcome = ran
await settle(
outcome.ok
? (input.plan.settledOutcome?.(outcome.value) ?? {
@@ -48,29 +64,42 @@ export async function runSettledAgentSessionMutation(input: {
)
return outcome
} catch (error) {
- // The pre-run uncertainty is already durable; refusing before dispatch adds no new uncertainty.
- if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) {
- throw error
- }
const { logger, sessionId } = input.context
- try {
- await settle({ status: 'unknown' })
- } catch {
- // Bookkeeping must not replace the operation's proof of whether dispatch began.
- logger.warn('recording an operation as unknown failed', {
- scope: 'operation-unknown-settlement',
- sessionId,
- operationId: input.envelope.clientOperationId
- })
+ // Its success commits with its write, so a throw leaves the row pending: nothing was written.
+ if (!input.plan.settlesWithWrite) {
+ try {
+ await settle({ status: 'unknown' })
+ } catch {
+ // Bookkeeping must not replace the operation's proof of whether dispatch began.
+ logger.warn('recording an operation as unknown failed', {
+ scope: 'operation-unknown-settlement',
+ sessionId,
+ operationId: operation.operationId
+ })
+ }
}
if (outcome && !outcome.ok) {
logger.warn('recording a refused operation failed', {
scope: 'operation-refused-settlement',
sessionId,
- operationId: input.envelope.clientOperationId
+ operationId: operation.operationId
})
return outcome
}
throw error
}
}
+
+function observedReceipt(
+ receipt: JournalOperationReceipt
+): JournalOperationReceipt & { wasCommitted: () => boolean } {
+ let committed = false
+ return {
+ write: receipt.write,
+ committed: () => {
+ receipt.committed()
+ committed = true
+ },
+ wasCommitted: () => committed
+ }
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts
index 2be4a8ea12b..144a43302b7 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts
@@ -114,8 +114,10 @@ describe('/clear', () => {
try {
const cleared = command('clear')
await eventually(() => expect(committing).toHaveBeenCalledOnce())
- expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL)
+ // Judged on arrival, answered on its turn: behind the clear.
+ const sent = rig.send('sent while clearing', 'queue-if-active').result
release?.()
+ expect(await sent).toEqual(WAIT_REFUSAL)
const done = await cleared
const replacementId = done.ok ? done.value.replacementSessionId : undefined
if (!replacementId) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
index ac1fdbb0617..176fd8e11fb 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
@@ -23,6 +23,7 @@ import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-messag
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
+import type { AgentSessionTurnContext } from './structured-agent-session-turns'
import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages'
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
@@ -185,11 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend(
context: {
deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } }
},
- ctx: {
- sessionId: string
- journal: AgentSessionJournal
- fence: number
- },
+ ctx: Pick,
params: {
envelope: { clientOperationId: string }
body: AgentJournalMessageItem
@@ -231,12 +228,15 @@ export async function maybeQueueStructuredAgentSessionSend(
}
// The insert notifies through the journal's commit listener: publication and
// the drain re-derive with no call here to forget.
- const row = await ctx.journal.queuedMessages.insert({
- messageId: clientMessageId,
- body: params.body,
- fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body),
- hostInstance: structuredAgentSessionHostInstance()
- })
+ const row = await ctx.journal.queuedMessages.insert(
+ {
+ messageId: clientMessageId,
+ body: params.body,
+ fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body),
+ hostInstance: structuredAgentSessionHostInstance()
+ },
+ ctx.operationReceipt
+ )
return {
ok: true,
value: {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts
index 00fa33a30fb..cea36c6546a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts
@@ -13,6 +13,16 @@ export type AgentSessionReplayOutcomeDecision =
| { decision: 'rerun' }
| { decision: 'refuse'; refusal: AgentSessionWireRefusal }
+/** A recorded id this host cannot answer from what it holds, so it neither ran it again nor
+ * refused it: the caller resends under the same id. */
+export function agentSessionOperationOutcomeUnknown(operationId: string): AgentSessionWireRefusal {
+ return refuse(
+ 'agent_session_operation_unknown',
+ { reason: 'outcomeUnknown' },
+ `The outcome of operation ${operationId} is unknown; it was not run again.`
+ )
+}
+
export function resolveAgentSessionReplayOutcome(input: {
operationId: string
outcome: AgentSessionOperationOutcome
@@ -41,14 +51,7 @@ export function resolveAgentSessionReplayOutcome(input: {
if (input.rerunWhenReplayMissing) {
return { decision: 'rerun' }
}
- return {
- decision: 'refuse',
- refusal: refuse(
- 'agent_session_operation_unknown',
- { reason: 'outcomeUnknown' },
- `The outcome of operation ${operationId} is unknown; it was not run again.`
- )
- }
+ return { decision: 'refuse', refusal: agentSessionOperationOutcomeUnknown(operationId) }
}
const recorded = input.reconstruct()
if (recorded) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts
new file mode 100644
index 00000000000..74e4cfb4815
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts
@@ -0,0 +1,241 @@
+// What a resend of a send id gets: the answer its record holds, never a refusal made before the
+// host looked the id up, and never a made-up record.
+
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
+import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
+import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons'
+import { persistRewindRecord } from './structured-rewind-recovery'
+import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
+import type { StructuredAgentSessionHost } from './structured-agent-session-host'
+import {
+ attach,
+ CALLER,
+ envelope,
+ hostTestState
+} from './structured-agent-session-host-test-harness'
+import {
+ HOST_TEST_SESSION as SESSION,
+ HOST_TEST_THREAD as THREAD,
+ hostTestMessage
+} from './structured-agent-session-host-test-data'
+import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture'
+
+let root: string
+let store: AgentSessionRecordStore
+let host: StructuredAgentSessionHost
+let dispatch: Mock
+let acquire: Mock
+
+beforeEach(() => {
+ ;({ root, store, host, dispatch, acquire } = hostTestState())
+})
+
+afterEach(() => vi.restoreAllMocks())
+
+function hostJournal(): AgentSessionJournal {
+ return (
+ host as unknown as { sessions: Map }
+ ).sessions.get(SESSION)!.journal
+}
+
+function sendParams(text: string) {
+ const body = hostTestMessage(text)
+ return { envelope: envelope('agentSession.send', { body }), body }
+}
+
+async function deliveredOnce(): Promise {
+ await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1))
+}
+
+describe('a resent send id', () => {
+ it('is answered from a refused row without opening the chat again', async () => {
+ await attach()
+ vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full'))
+ const params = sendParams('refused once')
+ const first = await host.send(CALLER, params)
+ expect(first).toMatchObject({
+ ok: false,
+ refusal: { code: 'agent_session_operation_invalid' }
+ })
+ await host.close(SESSION, 'evict')
+ expect(host.hasSession(SESSION)).toBe(false)
+
+ const resent = await host.send(CALLER, params)
+
+ expect(resent).toMatchObject({
+ ok: false,
+ refusal: {
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'journalWriteFailed' }
+ }
+ })
+ // The answer came from the ledger alone: the closed chat was not opened to give it.
+ expect(host.hasSession(SESSION)).toBe(false)
+ expect(dispatch).not.toHaveBeenCalled()
+ })
+
+ it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => {
+ await attach()
+ const params = sendParams('recorded, then the chat would not open')
+ await host.send(CALLER, params)
+ await deliveredOnce()
+ await host.close(SESSION, 'evict')
+ const connection = openTestJournalHostDatabase(root).db
+ const prepare = connection.prepare.bind(connection)
+ vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => {
+ if (sql.includes('journal_')) {
+ throw Object.assign(new Error('database disk image is malformed'), {
+ code: 'ERR_SQLITE_ERROR',
+ errcode: 11
+ })
+ }
+ return prepare(sql)
+ })
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({
+ ok: false,
+ refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } }
+ })
+ // A new id meets the same chat as a first run, and is refused for what it is.
+ await expect(host.send(CALLER, sendParams('a new message'))).resolves.toMatchObject({
+ ok: false,
+ refusal: { code: 'agent_session_journal_unreadable' }
+ })
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+
+ it('is answered from its record while a /clear is in flight; a new id is refused', async () => {
+ await attach()
+ const params = sendParams('sent before the clear')
+ expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false })
+
+ const clearing = host.conversationCommand(CALLER, {
+ command: 'clear',
+ envelope: envelope('agentSession.conversationCommand', { command: 'clear' })
+ })
+ const resent = host.send(CALLER, params)
+ const fresh = host.send(CALLER, sendParams('typed during the clear'))
+ await clearing
+
+ await expect(resent).resolves.toMatchObject({
+ ok: true,
+ replayed: true,
+ value: { submission: { clientMessageId: params.envelope.clientOperationId } }
+ })
+ await expect(fresh).resolves.toMatchObject({
+ ok: false,
+ refusal: { details: { reason: 'conversationCommandInFlight' } }
+ })
+ })
+
+ it('waits for an original still being accepted and answers with its submission', async () => {
+ await attach()
+ const journal = hostJournal()
+ const append = journal.appendSubmission.bind(journal)
+ let release!: () => void
+ const held = new Promise((resolve) => {
+ release = resolve
+ })
+ vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => {
+ await held
+ return append(...args)
+ })
+ const params = sendParams('resent while the first is mid-write')
+
+ const original = host.send(CALLER, params)
+ const resent = host.send(CALLER, params)
+ await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1))
+ release()
+
+ const [first, second] = await Promise.all([original, resent])
+ expect(first).toMatchObject({ ok: true, replayed: false })
+ expect(second).toMatchObject({
+ ok: true,
+ replayed: true,
+ value: { submission: { clientMessageId: params.envelope.clientOperationId } }
+ })
+ if (!second.ok || !('submission' in second.value)) {
+ throw new Error('expected the submission arm')
+ }
+ expect(second.value.submission.reason).not.toBe(DISPATCH_DOUBT_SUBMISSION_MISSING)
+ await deliveredOnce()
+ expect(journal.submissions()).toHaveLength(1)
+ expect(
+ store
+ .listOperationRows()
+ .filter((row) => row.operationId === params.envelope.clientOperationId)
+ ).toHaveLength(1)
+ })
+
+ it('is answered from the chat while a rewind is in doubt, starting no agent', async () => {
+ await attach()
+ const params = sendParams('sent before the rewind')
+ await host.send(CALLER, params)
+ await deliveredOnce()
+ await host.close(SESSION, 'evict')
+ // Only the provider can settle this rewind, so a send's first run would start it.
+ await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, {
+ operationId: 'rewind-op',
+ callerKey: CALLER.callerKey,
+ itemId: 'orca:rewound',
+ providerItemId: `codex:${THREAD}:turn-1:0`,
+ expectedEpoch: 'epoch-before',
+ phase: 'prepared',
+ retained: []
+ })
+ acquire.mockClear()
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({
+ ok: true,
+ replayed: true,
+ value: { submission: { clientMessageId: params.envelope.clientOperationId } }
+ })
+ expect(acquire).not.toHaveBeenCalled()
+ expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared')
+ })
+
+ it('is answered from a store a newer Orca wrote, which takes no write', async () => {
+ await attach()
+ const params = sendParams('sent, then a newer Orca wrote the store')
+ await host.send(CALLER, params)
+ await deliveredOnce()
+ await host.close(SESSION, 'evict')
+ Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true })
+ expect(store.readOnly).toBe(true)
+
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({
+ ok: true,
+ replayed: true,
+ value: { submission: { clientMessageId: params.envelope.clientOperationId } }
+ })
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('a send queued behind a running turn', () => {
+ it('commits its accepted row with its draft, so a failed settlement loses no answer', async () => {
+ const rig = await createQueuedMessageTestRig()
+ try {
+ await rig.workingSend()
+ const settle = vi
+ .spyOn(rig.store, 'recordOperationOutcome')
+ .mockRejectedValue(new Error('operation settlement failed'))
+ const queued = rig.send('queued behind the turn', 'queue-if-active')
+
+ await expect(queued.result).resolves.toMatchObject({
+ ok: true,
+ value: { queued: { messageId: queued.id, state: 'waiting' } }
+ })
+ expect(
+ rig.store.listOperationRows().find((row) => row.operationId === queued.id)
+ ).toMatchObject({ outcome: { status: 'succeeded' } })
+ expect(settle).not.toHaveBeenCalled()
+ } finally {
+ await rig.dispose()
+ }
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts
new file mode 100644
index 00000000000..e286c8a58ae
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts
@@ -0,0 +1,157 @@
+// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no
+// write. A /clear in flight refuses only a send's first run, judged when the send arrived.
+
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority'
+import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
+import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
+import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
+import type { StructuredAgentSessionHost } from './structured-agent-session-host'
+import {
+ attach,
+ CALLER,
+ envelope,
+ hostTestState
+} from './structured-agent-session-host-test-harness'
+import {
+ HOST_TEST_NOW as NOW,
+ HOST_TEST_SESSION as SESSION,
+ hostTestMessage
+} from './structured-agent-session-host-test-data'
+
+const EXPIRED = {
+ ok: false,
+ refusal: {
+ code: 'agent_session_operation_expired',
+ details: { reason: 'operationExpired' }
+ }
+}
+
+let root: string
+let store: AgentSessionRecordStore
+let host: StructuredAgentSessionHost
+let dispatch: Mock
+
+beforeEach(() => {
+ ;({ root, store, host, dispatch } = hostTestState())
+})
+
+afterEach(() => vi.restoreAllMocks())
+
+function hostJournal(): AgentSessionJournal {
+ return (
+ host as unknown as { sessions: Map }
+ ).sessions.get(SESSION)!.journal
+}
+
+function sendParams(text: string, clientOperationId?: string) {
+ const body = hostTestMessage(text)
+ return {
+ envelope: envelope(
+ 'agentSession.send',
+ { body },
+ clientOperationId ? { clientOperationId } : {}
+ ),
+ body
+ }
+}
+
+/** An id minted more than a day ago, which no ledger row holds any more. */
+function expiredParams(text: string) {
+ return sendParams(
+ text,
+ `${NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000}-${'e'.repeat(32)}`
+ )
+}
+
+function clear() {
+ return host.conversationCommand(CALLER, {
+ command: 'clear',
+ envelope: envelope('agentSession.conversationCommand', { command: 'clear' })
+ })
+}
+
+describe('an expired send id', () => {
+ it('is answered expired while its chat is closed, not as a chat this host lacks', async () => {
+ await attach()
+ await host.close(SESSION, 'evict')
+
+ await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED)
+ expect(host.hasSession(SESSION)).toBe(false)
+ })
+
+ it('is answered expired by a store a newer Orca wrote', async () => {
+ await attach()
+ const database = openTestJournalHostDatabase(root)
+ Object.defineProperty(database, 'readOnly', { value: true })
+ expect(store.readOnly).toBe(true)
+ try {
+ await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED)
+ } finally {
+ // Teardown stops the live child, which writes.
+ Object.defineProperty(database, 'readOnly', { value: false })
+ }
+ })
+
+ it('is answered expired when its row lapses while the chat opens for the resend', async () => {
+ await attach()
+ const params = sendParams('recorded, then it lapsed')
+ await host.send(CALLER, params)
+ await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1))
+ const evaluate = store.evaluateMutationOperation
+ // The second read, after the open, sees the clock past the row's whole retention.
+ vi.spyOn(store, 'evaluateMutationOperation')
+ .mockImplementationOnce(evaluate)
+ .mockImplementationOnce((args) =>
+ evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS })
+ )
+
+ await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED)
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('a /clear in flight', () => {
+ it('answers a resend from the attempt queued ahead of it, delivering once', async () => {
+ await attach()
+ const journal = hostJournal()
+ const append = journal.appendSubmission.bind(journal)
+ const held = Promise.withResolvers()
+ // An earlier send holds the session's queue, so attempt 1 waits there when the clear arrives.
+ vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => {
+ await held.promise
+ return append(...args)
+ })
+ const earlier = host.send(CALLER, sendParams('holds the queue'))
+ const params = sendParams('queued ahead of the clear')
+ const id = params.envelope.clientOperationId
+ const attempt = host.send(CALLER, params)
+ await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1))
+
+ const clearing = clear()
+ const resent = host.send(CALLER, params)
+ held.resolve()
+ const [first, second] = await Promise.all([attempt, resent, earlier, clearing])
+
+ expect(first).toMatchObject({ ok: true, replayed: false })
+ expect(second).toMatchObject({
+ ok: true,
+ replayed: true,
+ value: { submission: { clientMessageId: id } }
+ })
+ expect(journal.submissions().filter((entry) => entry.clientMessageId === id)).toHaveLength(1)
+ expect(
+ dispatch.mock.calls.filter(([input]) => input.clientMessageId === id).length
+ ).toBeLessThanOrEqual(1)
+ })
+
+ it('answers an expired id expired', async () => {
+ await attach()
+ const clearing = clear()
+ const expired = host.send(CALLER, expiredParams('typed long ago'))
+ await clearing
+
+ await expect(expired).resolves.toMatchObject(EXPIRED)
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts
index f1fe0a40b42..b1cbc83a48e 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts
@@ -5,10 +5,10 @@ import {
AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE
} from '../../../shared/agent-session-restart-continuation'
import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
-import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring'
import {
interruptedRestart,
- statusNotes
+ statusNotes,
+ throwAfterContinuationAccepted
} from './structured-agent-session-restart-interruption-test-harness'
import { CALLER, envelope } from './structured-agent-session-host-test-harness'
import {
@@ -66,19 +66,10 @@ it('says so in the chat when the agent cannot start for the continuation', async
// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed,
// and it stays on record while the agent that may be carrying on keeps running.
it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => {
- const { host, store } = await interruptedRestart()
+ const { host } = await interruptedRestart()
vi.spyOn(console, 'warn').mockImplementation(() => {})
await host.restartResume.list()
- const settle = store.recordOperationOutcome.bind(store)
- vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => {
- if (
- input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER &&
- input.outcome.status === 'succeeded'
- ) {
- throw new Error('operation outcome could not be persisted')
- }
- return settle(input)
- })
+ throwAfterContinuationAccepted()
const result = await host.restartResume.continueAfterRestart([SESSION], 'modal')
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
index 94746daf3ec..fe97834a189 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts
@@ -13,6 +13,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
+import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
@@ -160,6 +161,21 @@ export async function interruptedRestart(
return { ...hostTestState(), host, store, log, closeSession, marker, clock }
}
+/** The continuation's submission commits, then its send throws: a send Orca may have taken. */
+export function throwAfterContinuationAccepted(): void {
+ const append = AgentSessionJournal.prototype.appendSubmission
+ vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function (
+ this: AgentSessionJournal,
+ ...args: Parameters
+ ) {
+ const cursor = await append.apply(this, args)
+ if (args[0].origin === 'host') {
+ throw new Error('the accepted continuation could not be answered')
+ }
+ return cursor
+ })
+}
+
export async function statusNotes(host: StructuredAgentSessionHost) {
return (await host.journalSnapshot(SESSION)).items.flatMap((item) =>
item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : []
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
index a46551b4500..4019582e1fd 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
@@ -18,7 +18,8 @@ import {
interruptedRestart,
startAgent,
statusNotes,
- supersededRefusal
+ supersededRefusal,
+ throwAfterContinuationAccepted
} from './structured-agent-session-restart-interruption-test-harness'
import {
attach,
@@ -141,38 +142,30 @@ it('replays the same logical continuation through the durable send ledger', asyn
})
// A send that throws after Orca may have taken it is not proof it was not delivered.
-it.each([false, true])(
- 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)',
- async (uncertaintyFails) => {
- const { host, store } = await interruptedRestart()
- const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
- expect(await host.restartResume.list()).toHaveLength(1)
- const settle = store.recordOperationOutcome.bind(store)
- const recording = vi.spyOn(store, 'recordOperationOutcome')
- recording.mockImplementation(async (input) => {
- // Only the continuation's own record: the start it makes records its attach as usual.
- if (
- input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER &&
- (input.outcome.status === 'succeeded' || uncertaintyFails)
- ) {
- throw new Error('operation outcome could not be persisted')
- }
- return settle(input)
- })
+it('keeps a continuation unconfirmed when its send throws after acceptance', async () => {
+ const { host, store } = await interruptedRestart()
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ expect(await host.restartResume.list()).toHaveLength(1)
+ throwAfterContinuationAccepted()
- const result = await host.restartResume.continueAfterRestart([SESSION], 'modal')
+ const result = await host.restartResume
+ .continueAfterRestart([SESSION], 'modal')
+ .finally(() => vi.restoreAllMocks())
- expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }])
- // Filed as unconfirmed, with a warning in the chat.
- expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }])
- expect(await statusNotes(host)).toContainEqual({
- text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE,
- tone: 'warning'
- })
- recording.mockRestore()
- warning.mockRestore()
- }
-)
+ expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }])
+ // Filed as unconfirmed, with a warning in the chat.
+ expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }])
+ expect(await statusNotes(host)).toContainEqual({
+ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE,
+ tone: 'warning'
+ })
+ // Its acceptance committed with its submission: a resend replays it.
+ expect(
+ store
+ .listOperationRows()
+ .find((row) => row.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER)
+ ).toMatchObject({ outcome: { status: 'succeeded' } })
+})
// The continuation is accepted, then its start fails: the message is rejected with the cause and
// the failure is filed, and nothing is stopped because nothing started.
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts
index cf746e29b74..01f4868d380 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts
@@ -274,10 +274,12 @@ describe('a send with no live owner', () => {
expect(acquire).toHaveBeenCalledOnce()
})
- it('restarts nothing for a send the ledger holds but the journal never saw', async () => {
- const params = sendParams('claimed, then the host died')
- // The row was claimed and the host went down before the journal write: on replay, admission
- // reconstructs an unknown-outcome submission and never needs an owner.
+ /** A row admitted for this send, then the host died before the journal write, left as `outcome`
+ * says: `pending` by this build, `unknown` by a build that marked it before running. */
+ async function admittedThenHostDied(
+ params: ReturnType,
+ outcome: 'pending' | 'unknown'
+ ) {
await store.admitMutationOperation({
callerKey: CALLER.callerKey,
envelope: params.envelope,
@@ -285,11 +287,13 @@ describe('a send with no live owner', () => {
now: NOW,
operationIdScope: 'global'
})
- await store.recordOperationOutcome({
- callerKey: CALLER.callerKey,
- operationId: params.envelope.clientOperationId,
- outcome: { status: 'unknown' }
- })
+ if (outcome === 'unknown') {
+ await store.recordOperationOutcome({
+ callerKey: CALLER.callerKey,
+ operationId: params.envelope.clientOperationId,
+ outcome: { status: 'unknown' }
+ })
+ }
await host.handleAdapterEvent({
type: 'ended',
sessionId: SESSION,
@@ -300,16 +304,31 @@ describe('a send with no live owner', () => {
})
expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released')
acquire.mockClear()
-
- const result = await host.send(CALLER, {
+ return {
...params,
envelope: {
...params.envelope,
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0
}
- })
+ }
+ }
- expect(result).toMatchObject({
+ it('runs a send admitted but never run for the first time, restarting the owner once', async () => {
+ const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending')
+
+ await expect(host.send(CALLER, resent)).resolves.toMatchObject({
+ ok: true,
+ replayed: false,
+ value: { submission: { dispatchState: 'pending' } }
+ })
+ await eventually(async () => expect(dispatch).toHaveBeenCalledOnce())
+ expect(acquire).toHaveBeenCalledOnce()
+ })
+
+ it("restarts nothing for an older build's unknown row the journal never saw", async () => {
+ const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown')
+
+ await expect(host.send(CALLER, resent)).resolves.toMatchObject({
ok: true,
replayed: true,
value: { submission: { dispatchState: 'unknown', recovered: true } }
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
index 77413f45634..e74947054e4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts
@@ -22,7 +22,9 @@ import {
AGENT_SESSION_NOT_ATTACHED,
type AgentSessionMutationSessionPreparation
} from './structured-agent-session-mutation-admission'
+import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome'
import { rewindRefusal } from './structured-rewind-refusal'
+import { conversationCommandInFlight } from './structured-conversation-command-admission'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
@@ -132,17 +134,29 @@ export function openWithAgent(
}
/** A rewind still in doubt once the conversation is open is one only its provider can settle —
- * the open settles every other — so a send starts the agent, whose attach recovers it. */
+ * the open settles every other — so a send starts the agent, whose attach recovers it. A resend
+ * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an
+ * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running
+ * when this send arrived, which refuses only its first run. */
export function sendPreparation(
context: Pick,
- envelope: AgentSessionMutationEnvelope
-): () => Promise {
- return async () => {
+ envelope: AgentSessionMutationEnvelope,
+ arrival: { clearInFlight?: boolean } = {}
+): (ledger: 'admit' | 'replay') => Promise {
+ return async (ledger) => {
+ if (ledger === 'admit' && arrival.clearInFlight) {
+ return { ok: false, refusal: conversationCommandInFlight() }
+ }
const opened = await openConversationForWrite(
context.openConversation,
envelope,
context.deps.logger
)
+ if (ledger === 'replay') {
+ return opened.ok
+ ? opened
+ : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) }
+ }
const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase
return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded')
? context.ensureAgent(envelope.sessionId)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
index f0163b22791..f382eda54e4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts
@@ -303,23 +303,20 @@ describe('send', () => {
expect(journal.submissions()).toHaveLength(1)
})
- it('reconstructs an accepted send after the ledger settlement is lost', async () => {
+ it('answers a send whose ledger settlement fails, its row committed with the submission', async () => {
await attach()
- const persist = store.recordOperationOutcome.bind(store)
- let failSettlement = true
- vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => {
- if (failSettlement && input.outcome.status === 'succeeded') {
- failSettlement = false
- throw new Error('operation settlement failed')
- }
- return persist(input)
- })
- const body = hostTestMessage('accepted before settlement failed')
+ vi.spyOn(store, 'recordOperationOutcome').mockRejectedValue(
+ new Error('operation settlement failed')
+ )
+ const body = hostTestMessage('accepted while settlement fails')
const params = { envelope: envelope('agentSession.send', { body }), body }
+ const clientMessageId = params.envelope.clientOperationId
- await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed')
- // The submission was recorded before the ledger write failed, so it is still delivered.
- await delivered(params.envelope.clientOperationId)
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false })
+ expect(
+ store.listOperationRows().find((row) => row.operationId === clientMessageId)
+ ).toMatchObject({ outcome: { status: 'succeeded' } })
+ await delivered(clientMessageId)
await expect(host.send(CALLER, params)).resolves.toMatchObject({
ok: true,
replayed: true,
@@ -328,51 +325,54 @@ describe('send', () => {
expect(dispatch).toHaveBeenCalledTimes(1)
})
- it('never reruns an admission-only send after the caller changes', async () => {
+ it('runs a send that threw before writing for the first time when it is resent, once', async () => {
await attach()
- const settlement = vi
- .spyOn(store, 'recordOperationOutcome')
- .mockRejectedValue(new Error('operation settlement failed'))
const body = hostTestMessage('first delivery after caller recovery')
const params = { envelope: envelope('agentSession.send', { body }), body }
+ const clientMessageId = params.envelope.clientOperationId
+ const beforeRun = () => {
+ throw new Error('host fault before the write')
+ }
- await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed')
- expect(dispatch).not.toHaveBeenCalled()
- settlement.mockRestore()
+ await expect(host.send(CALLER, { ...params, beforeRun })).rejects.toThrow(
+ 'host fault before the write'
+ )
+ expect(hostJournal().submissions()).toHaveLength(0)
+ // Its success would have committed with its write, so a row still pending wrote nothing.
+ expect(
+ store.listOperationRows().find((row) => row.operationId === clientMessageId)
+ ).toMatchObject({ outcome: { status: 'pending' } })
await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({
ok: true,
- replayed: true,
- value: {
- submission: {
- dispatchState: 'unknown',
- reason: DISPATCH_DOUBT_SUBMISSION_MISSING
- }
- }
+ replayed: false,
+ value: { submission: { clientMessageId, dispatchState: 'pending' } }
})
- expect(dispatch).not.toHaveBeenCalled()
+ await delivered(clientMessageId)
expect(
- store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId)
- ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } })
+ store.listOperationRows().find((row) => row.operationId === clientMessageId)
+ ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } })
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true })
+ expect(dispatch).toHaveBeenCalledTimes(1)
})
- it('never redelivers after admission survives without its journal submission', async () => {
+ it("answers an older build's unknown row a new epoch emptied as recorded, never redelivering", async () => {
await attach()
- const persist = store.recordOperationOutcome.bind(store)
- const settlement = vi
- .spyOn(store, 'recordOperationOutcome')
- .mockImplementation(async (input) => {
- if (input.outcome.status === 'succeeded') {
- throw new Error('operation settlement failed')
- }
- return persist(input)
- })
const body = hostTestMessage('delivered before epoch recovery')
const params = { envelope: envelope('agentSession.send', { body }), body }
-
- await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed')
- settlement.mockRestore()
+ await host.send(CALLER, params)
await delivered(params.envelope.clientOperationId)
+ // What a build that marked a send unknown before running it leaves behind.
+ await store.recordOperationOutcome({
+ callerKey: CALLER.callerKey,
+ operationId: params.envelope.clientOperationId,
+ outcome: { status: 'pending' }
+ })
+ await store.recordOperationOutcome({
+ callerKey: CALLER.callerKey,
+ operationId: params.envelope.clientOperationId,
+ outcome: { status: 'unknown' }
+ })
const journal = hostJournal()
await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1)
expect(journal.submissions()).toHaveLength(0)
@@ -394,33 +394,22 @@ describe('send', () => {
expect(journal.submissions()).toHaveLength(0)
})
- it('fails closed when a legacy pending row survives without its submission', async () => {
+ it('answers an accepted send a new epoch dropped as recorded, never by running it again', async () => {
await attach()
- const body = hostTestMessage('legacy pending send after caller recovery')
+ const body = hostTestMessage('accepted, then the epoch was replaced')
const params = { envelope: envelope('agentSession.send', { body }), body }
-
await host.send(CALLER, params)
await delivered(params.envelope.clientOperationId)
- expect(dispatch).toHaveBeenCalledTimes(1)
- await store.recordOperationOutcome({
- callerKey: CALLER.callerKey,
- operationId: params.envelope.clientOperationId,
- outcome: { status: 'pending' }
- })
+ await hostJournal().rollEpoch(
+ 'schema_unreadable',
+ store.getRecord(SESSION)?.lease.runtimeFence ?? 1
+ )
- const journal = (
- host as unknown as { sessions: Map }
- ).sessions.get(SESSION)!.journal
- await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1)
-
- await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({
+ await expect(host.send(CALLER, params)).resolves.toMatchObject({
ok: true,
replayed: true,
value: {
- submission: {
- dispatchState: 'unknown',
- reason: DISPATCH_DOUBT_SUBMISSION_MISSING
- }
+ submission: { dispatchState: 'unknown', reason: DISPATCH_DOUBT_SUBMISSION_MISSING }
}
})
expect(dispatch).toHaveBeenCalledTimes(1)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts
new file mode 100644
index 00000000000..4d33a998a59
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts
@@ -0,0 +1,89 @@
+import { expect, vi } from 'vitest'
+import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait'
+import type { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter'
+import type {
+ fakeClaude,
+ FakeConnection
+} from '../../claude/claude-structured-session-test-support'
+import type { StructuredAgentSessionHost } from './structured-agent-session-host'
+
+export function createStoppedClaudeDeadline(deps: {
+ host: () => Pick
+ adapter: () => Pick
+ claude: () => ReturnType
+ sessionId: string
+ stop: (turnId?: string) => ReturnType