diff --git a/README.md b/README.md
index 136b04ef090..095b0f486e9 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
+- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 1a2bb248fd6..f7d8172cb5f 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -2736,6 +2736,151 @@
],
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
},
+ {
+ "id": "terminal-query.mobile-view-authority",
+ "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-runtime",
+ "layer": "mobile-runtime-rpc-contract",
+ "surfaces": [
+ "mobile terminal WebView",
+ "terminal query authority",
+ "runtime RPC",
+ "mobile subscription replay",
+ "multi-mobile input floor"
+ ],
+ "platforms": [
+ "macos",
+ "linux",
+ "windows",
+ "mobile"
+ ],
+ "providers": [
+ "local",
+ "daemon",
+ "ssh",
+ "remote-runtime"
+ ],
+ "coveredPlatforms": [
+ "macos"
+ ],
+ "coveredProviders": [],
+ "coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/issues/8128",
+ "https://github.com/stablyai/orca/pull/8206"
+ ],
+ "invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.",
+ "oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.",
+ "commands": [
+ "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
+ ],
+ "testFiles": [
+ "mobile/src/terminal/terminal-webview-query-reply.test.ts",
+ "mobile/src/terminal/terminal-webview-query-reply-routing.test.ts",
+ "mobile/src/terminal/mobile-terminal-query-reply.test.ts",
+ "src/main/runtime/rpc/terminal-send.test.ts",
+ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
+ "src/main/runtime/mobile-presence-lock.test.ts",
+ "src/shared/terminal-query-reply.test.ts",
+ "src/shared/terminal-reply-query-scan.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "mobile/src/terminal/terminal-webview-query-reply.test.ts",
+ "assertions": [
+ "snapshot replay emits no terminal-data message before the replay boundary",
+ "a live query after the boundary emits exactly one terminal-data message",
+ "a superseded terminal generation cannot regain reply authority",
+ "xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert"
+ ]
+ },
+ {
+ "file": "src/main/runtime/rpc/terminal-send.test.ts",
+ "assertions": [
+ "only the elected mobile subscriber can write a claimed query reply",
+ "valid replies skip mobileTookFloor",
+ "ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected"
+ ]
+ },
+ {
+ "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
+ "assertions": [
+ "live terminal data and view authority register before asynchronous mobile fit",
+ "a query received during that await is retained and emitted after the snapshot"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "assertions": [
+ "desktop xterm onData replies are dropped while mobile owns the terminal driver",
+ "desktop capability-handler replies outside onData honor the same mobile authority lock"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-10",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
+ "result": "passed",
+ "durationSeconds": 1,
+ "summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed."
+ },
+ {
+ "date": "2026-07-10",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
+ "result": "passed",
+ "durationSeconds": 4,
+ "summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes."
+ },
+ {
+ "date": "2026-07-10",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "result": "passed",
+ "durationSeconds": 11,
+ "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 20,
+ "scope": "mobile and runtime deterministic unit gate"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue."
+ },
+ "promotionCriteria": [
+ "Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.",
+ "Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.",
+ "Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.",
+ "Accumulate the normal soak window with zero unexplained flakes."
+ ],
+ "knownGaps": [
+ "No live iOS or Android WebView run is registered.",
+ "No real SSH, WSL, daemon, or two-device provider-contract run is registered.",
+ "Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.",
+ "A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands."
+ ],
+ "demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use."
+ },
{
"id": "terminal-runtime.mobile-stream-budget",
"title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded",
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index 749d07b5811..f8d168e5f62 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
-[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,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.26/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index b19a58f5012..6c5e1bbfff5 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.26/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.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,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.26/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 9848afd6ef5..ce9a13cc30b 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](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,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.26/app-release.apk)
+- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 5dd3eee6274..d81c6077879 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,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) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Baixar APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
+- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 7fe73c4b074..c771506ed7f 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.26/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.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,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.26/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md
index e05b5768836..bb6b11fea8c 100644
--- a/docs/reference/terminal-query-authority.md
+++ b/docs/reference/terminal-query-authority.md
@@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time):
stream (CLI `terminal.read`, automation observers) do not suppress — they
also do not answer; that bounded no-reply case matches today's behavior.
+Mobile streams preserve that singularity across snapshot startup and multiple
+views. The mobile WebView suppresses `onData` during snapshot replay, then
+enables replies at an explicit live-output boundary. Clearing the WebView drops
+that replay queue and resumes live reply authority immediately. If a live query
+arrived while the initial snapshot was being built and its output sequence is
+covered by that snapshot, runtime RPC re-emits only the bounded query sequence
+after the snapshot; ordinary covered output stays deduplicated. `terminal.send`
+accepts the resulting `inputKind: query-reply` only from the earliest active
+phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest
+is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not
+change reply ownership; passive desktop-mode watchers are excluded. Peer phones
+are rejected, the next subscriber is promoted on unsubscribe, and desktop
+parser and capability handlers stay silent until desktop retakes the driver.
+Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile
+client never forwards replies to a host that lacks it, because such hosts
+strip `inputKind` and would treat the bytes as floor-taking shell input.
+Residual: a desktop→mobile driver handoff has a bounded double-reply window
+until the async driver-change event reaches the desktop renderer's cached
+driver map (`isPtyLocked`).
+
Everything the emulator emits outside a forwarding window is discarded, which
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
triggered by option mutations).
## Reply classes
-| Class | Queries | Answer source |
-| --- | --- | --- |
-| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
-| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
-| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
-| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
-| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
-| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
+| Class | Queries | Answer source |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
+| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
+| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
+| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
+| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
+| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
### View-attribute bridge
@@ -237,7 +257,8 @@ any spawn-window drops).
the documented ConPTY DA1 variant and the view-attribute parser handlers
the headless core cannot serve.
6. Remote views keep view authority; main yields whenever a remote view
- subscriber is attached.
+ subscriber is attached. When multiple desktop/mobile views coexist, the
+ terminal driver and server-side mobile election keep one reply writer.
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
replaced by:
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 18a393cae28..1b63bf59b7a 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -112,6 +112,8 @@ import {
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
+import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
+import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
@@ -2424,10 +2426,13 @@ export default function SessionScreen() {
terminalGestureInputInFlightRef.current.clear()
}, [connState])
+ const hostQueryReplyInputSupportedRef = useRef(false)
+
useEffect(() => {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
+ hostQueryReplyInputSupportedRef.current = false
return
}
let stale = false
@@ -2444,11 +2449,16 @@ export default function SessionScreen() {
setAgentSessionHistorySupported(
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
)
+ // Why: hosts without this capability strip inputKind from terminal.send,
+ // so a forwarded xterm reply would become floor-stealing shell input.
+ hostQueryReplyInputSupportedRef.current =
+ status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
})
.catch(() => {
if (!stale) {
setBrowserScreencastSupported(false)
setAgentSessionHistorySupported(false)
+ hostQueryReplyInputSupportedRef.current = false
}
})
return () => {
@@ -3531,6 +3541,18 @@ export default function SessionScreen() {
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
+ const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
+ void sendMobileTerminalQueryReply({
+ bytes,
+ client: clientRef.current,
+ clientId: deviceTokenRef.current,
+ connected: connStateRef.current === 'connected',
+ handle,
+ hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current,
+ subscribedTerminals: terminalUnsubsRef.current
+ })
+ }, [])
+
async function handleClearTerminal(target: Terminal) {
if (!client) {
return
@@ -4800,6 +4822,7 @@ export default function SessionScreen() {
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
+ onTerminalQueryReply={handleTerminalQueryReply}
onTerminalTap={handleTerminalTap}
onFileTap={handleFileTap}
onOpenUrl={handleTerminalOpenUrl}
diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx
index beb8118dabf..06174cd0378 100644
--- a/mobile/src/session/TerminalPaneView.tsx
+++ b/mobile/src/session/TerminalPaneView.tsx
@@ -23,6 +23,7 @@ type TerminalPaneViewProps = {
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
+ onTerminalQueryReply: (handle: string, bytes: string) => void
onTerminalTap: (handle: string) => void
onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
onOpenUrl: (handle: string, url: string) => void
@@ -44,6 +45,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
+ onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -80,6 +82,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
+ onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
onTerminalTap={() => onTerminalTap(handle)}
onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
onOpenUrl={(url) => onOpenUrl(handle, url)}
diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx
index 4b84e44081a..7eded2ef5ba 100644
--- a/mobile/src/terminal/TerminalWebView.tsx
+++ b/mobile/src/terminal/TerminalWebView.tsx
@@ -1,7 +1,6 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { Platform, View } from 'react-native'
-import { WebView } from 'react-native-webview'
-import type { WebViewMessageEvent } from 'react-native-webview'
+import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
@@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
+import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
type Props = TerminalWebViewProps
@@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
+ onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef(function
} catch {
return
}
+ routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
@@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
+ onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts
new file mode 100644
index 00000000000..1b76a89574d
--- /dev/null
+++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply'
+
+function createClient() {
+ return {
+ sendRequest: vi.fn().mockResolvedValue({
+ id: '1',
+ ok: true,
+ result: { send: { accepted: true, bytesWritten: 6 } },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ } as unknown as Pick
+}
+
+describe('sendMobileTerminalQueryReply', () => {
+ it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => {
+ const client = createClient()
+
+ await expect(
+ sendMobileTerminalQueryReply({
+ bytes: '\x1b[3;4R',
+ client,
+ clientId: 'mobile-1',
+ connected: true,
+ handle: 'terminal-1',
+ hostSupportsQueryReplyInput: true,
+ subscribedTerminals: new Set(['terminal-1'])
+ })
+ ).resolves.toBe(true)
+
+ expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
+ terminal: 'terminal-1',
+ text: '\x1b[3;4R',
+ enter: false,
+ inputKind: 'query-reply',
+ client: { id: 'mobile-1', type: 'mobile' }
+ })
+ })
+
+ it.each([
+ ['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'],
+ ['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'],
+ ['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'],
+ ['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1']
+ ])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => {
+ const client = createClient()
+
+ await expect(
+ sendMobileTerminalQueryReply({
+ bytes,
+ client,
+ clientId: 'mobile-1',
+ connected,
+ handle,
+ hostSupportsQueryReplyInput: true,
+ subscribedTerminals
+ })
+ ).resolves.toBe(false)
+ expect(client.sendRequest).not.toHaveBeenCalled()
+ })
+
+ // Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops
+ // unknown keys) and would treat the reply as floor-taking shell input.
+ it('does not send to a host that has not advertised query-reply input support', async () => {
+ const client = createClient()
+
+ await expect(
+ sendMobileTerminalQueryReply({
+ bytes: '\x1b[3;4R',
+ client,
+ clientId: 'mobile-1',
+ connected: true,
+ handle: 'terminal-1',
+ hostSupportsQueryReplyInput: false,
+ subscribedTerminals: new Set(['terminal-1'])
+ })
+ ).resolves.toBe(false)
+ expect(client.sendRequest).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/terminal/mobile-terminal-query-reply.ts b/mobile/src/terminal/mobile-terminal-query-reply.ts
new file mode 100644
index 00000000000..4dd7ae7a8a5
--- /dev/null
+++ b/mobile/src/terminal/mobile-terminal-query-reply.ts
@@ -0,0 +1,51 @@
+import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
+import type { RpcClient } from '../transport/rpc-client'
+import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
+
+type TerminalSubscriptionRegistry = {
+ has: (handle: string) => boolean
+}
+
+type MobileTerminalQueryReplyOptions = {
+ bytes: string
+ client: Pick | null
+ clientId: string | null
+ connected: boolean
+ handle: string
+ hostSupportsQueryReplyInput: boolean
+ subscribedTerminals: TerminalSubscriptionRegistry
+}
+
+export function sendMobileTerminalQueryReply({
+ bytes,
+ client,
+ clientId,
+ connected,
+ handle,
+ hostSupportsQueryReplyInput,
+ subscribedTerminals
+}: MobileTerminalQueryReplyOptions): Promise {
+ // Why: every subscribed mobile xterm suppresses main's responder, including
+ // hidden panes, so ownership follows the subscription rather than focus.
+ // Hosts without terminal.query-reply-input.v1 strip inputKind and would take
+ // reply bytes as floor-stealing shell input, so drop (pre-fix behavior).
+ if (
+ !client ||
+ !connected ||
+ !hostSupportsQueryReplyInput ||
+ !subscribedTerminals.has(handle) ||
+ !isTerminalQueryReply(bytes)
+ ) {
+ return Promise.resolve(false)
+ }
+
+ return client
+ .sendRequest('terminal.send', {
+ terminal: handle,
+ text: bytes,
+ enter: false,
+ inputKind: 'query-reply',
+ ...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
+ })
+ .then(isTerminalSendRpcAccepted, () => false)
+}
diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts
index 808dece2648..63c155b7a23 100644
--- a/mobile/src/terminal/terminal-webview-contract.ts
+++ b/mobile/src/terminal/terminal-webview-contract.ts
@@ -28,6 +28,7 @@ export type TerminalSelectionEvents = {
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
+ onTerminalQueryReply?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index fcd5ea9cafa..69b2e6f6b51 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
+import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
@@ -215,7 +216,7 @@ window.onerror = function(msg) {
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
- var term = null;
+ var term = null; ${TERMINAL_QUERY_REPLY_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}
writeQueue.push(normalizeStatusDotPresentation(data));
}
+ function enqueueWriteBoundary(callback) {
+ writeQueue.push(callback);
+ }
+
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
@@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
+ if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
@@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
+ // Why: snapshot replay can contain old queries whose replies must never
+ // re-enter the live PTY. Each replacement terminal earns authority anew.
+ resetTerminalDataReplyAuthority();
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
@@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
- disableStdin: true,
+ // Why: xterm suppresses parser-generated query replies when disableStdin
+ // is true. Native accepts only validated reply grammars from onData.
+ disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
@@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
resetEvictionCounter();
cancelSelect();
attachTermObservers();
+ attachTerminalQueryReplyBridge(term, gen);
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
@@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
write(msg.data);
} else if (msg.type === 'clear') {
terminalGeneration++;
- resetWriteQueue();
+ resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts
new file mode 100644
index 00000000000..a4ac34e5809
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-query-reply-injected.ts
@@ -0,0 +1,44 @@
+// Kept as one injectable unit so tests execute the same replay/generation gate
+// that the WebView document runs, rather than a TypeScript reimplementation.
+export const TERMINAL_QUERY_REPLY_JS = `
+ var terminalDataRepliesEnabled = false;
+
+ function resetTerminalDataReplyAuthority() {
+ terminalDataRepliesEnabled = false;
+ }
+
+ function resumeTerminalDataReplyAuthority() {
+ terminalDataRepliesEnabled = true;
+ }
+
+ function forwardTerminalDataReply(data) {
+ if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
+ }
+
+ function enqueueTerminalDataReplyBoundary(gen) {
+ enqueueWriteBoundary(function() {
+ if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
+ });
+ }
+
+ function attachTerminalQueryReplyBridge(term, gen) {
+ // Why: parser replies require stdin enabled, but mobile input is owned by
+ // native controls. Keep xterm's textarea inert for touch/hardware keys.
+ try {
+ term.attachCustomKeyEventHandler(function() { return false; });
+ if (term.textarea) {
+ term.textarea.readOnly = true;
+ term.textarea.tabIndex = -1;
+ term.textarea.setAttribute('inputmode', 'none');
+ }
+ } catch (e) {}
+ try {
+ termObserverDisposables.push(term.onData(function(data) {
+ forwardTerminalDataReply(data);
+ }));
+ } catch (e) {}
+ // Why: live output can queue before initial replay finishes. Enable replies
+ // at the replay boundary so those live queries are answered, never replayed ones.
+ enqueueTerminalDataReplyBoundary(gen);
+ }
+`
diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts
new file mode 100644
index 00000000000..af53772d481
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts
@@ -0,0 +1,69 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TerminalWebView } from './TerminalWebView'
+
+vi.mock('react-native', () => ({
+ Platform: { OS: 'ios' },
+ StyleSheet: {
+ absoluteFillObject: {
+ bottom: 0,
+ left: 0,
+ position: 'absolute',
+ right: 0,
+ top: 0
+ },
+ create: (styles: unknown) => styles
+ },
+ Text: 'Text',
+ View: 'View'
+}))
+
+vi.mock('react-native-webview', async () => {
+ const React = await import('react')
+ const WebView = React.forwardRef((props: Record, _ref) =>
+ React.createElement('WebView', props)
+ )
+ return { WebView, default: WebView }
+})
+
+vi.mock('lucide-react-native', () => ({
+ RefreshCw: 'RefreshCw'
+}))
+
+let renderer: ReactTestRenderer | null = null
+
+function postWebViewMessage(payload: Record): void {
+ if (!renderer) {
+ throw new Error('TerminalWebView did not render')
+ }
+ const webView = renderer.root.findByType('WebView')
+ act(() => {
+ webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
+ })
+}
+
+describe('TerminalWebView query reply routing', () => {
+ afterEach(() => {
+ if (renderer) {
+ act(() => renderer?.unmount())
+ renderer = null
+ }
+ vi.restoreAllMocks()
+ })
+
+ it('routes only complete terminal query replies to native', () => {
+ const onTerminalQueryReply = vi.fn()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ act(() => {
+ renderer = create(createElement(TerminalWebView, { onTerminalQueryReply }))
+ })
+
+ postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' })
+ postWebViewMessage({ type: 'terminal-data', bytes: 'a' })
+ postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' })
+
+ expect(onTerminalQueryReply).toHaveBeenCalledTimes(1)
+ expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R')
+ })
+})
diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.ts
new file mode 100644
index 00000000000..cf79fb71b7a
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-query-reply-routing.ts
@@ -0,0 +1,14 @@
+import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
+
+export function routeTerminalQueryReply(
+ message: Record,
+ onTerminalQueryReply: ((bytes: string) => void) | undefined
+): void {
+ if (
+ message.type === 'terminal-data' &&
+ typeof message.bytes === 'string' &&
+ isTerminalQueryReply(message.bytes)
+ ) {
+ onTerminalQueryReply?.(message.bytes)
+ }
+}
diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts
new file mode 100644
index 00000000000..4e8add33f2a
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest'
+import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
+import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
+
+type QueryReplyGate = {
+ forward: (data: string) => void
+ queueBoundary: (generation: number) => void
+ reset: () => void
+ resume: () => void
+ setGeneration: (generation: number) => void
+}
+
+function createQueryReplyGate(notify: (message: unknown) => void): {
+ gate: QueryReplyGate
+ queuedBoundaries: Array<() => void>
+} {
+ const queuedBoundaries: Array<() => void> = []
+ const factory = new Function(
+ 'notify',
+ 'enqueueWriteBoundary',
+ `var terminalGeneration = 0;
+ ${TERMINAL_QUERY_REPLY_JS}
+ return {
+ forward: forwardTerminalDataReply,
+ queueBoundary: enqueueTerminalDataReplyBoundary,
+ reset: resetTerminalDataReplyAuthority,
+ resume: resumeTerminalDataReplyAuthority,
+ setGeneration: function(next) { terminalGeneration = next; }
+ };`
+ ) as (
+ notify: (message: unknown) => void,
+ enqueueBoundary: (callback: () => void) => void
+ ) => QueryReplyGate
+ const gate = factory(notify, (callback) => queuedBoundaries.push(callback))
+ return { gate, queuedBoundaries }
+}
+
+describe('mobile terminal query replies', () => {
+ it('forwards xterm-generated data only after initial replay drains', () => {
+ const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
+ const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
+ 'attachTerminalQueryReplyBridge(term, gen)',
+ listenerIndex
+ )
+ const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
+ 'forwardTerminalDataReply(data)',
+ listenerIndex
+ )
+
+ expect(listenerIndex).toBeGreaterThan(-1)
+ expect(enableIndex).toBeGreaterThan(listenerIndex)
+ expect(notifyIndex).toBeGreaterThan(listenerIndex)
+ expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
+ expect(XTERM_WEBVIEW_SOURCE.html).toContain(
+ 'term.attachCustomKeyEventHandler(function() { return false; })'
+ )
+ expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
+ })
+
+ it('mutes a replacement terminal until its own replay drains', () => {
+ const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData')
+ const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
+ 'resetTerminalDataReplyAuthority()',
+ initIndex
+ )
+ const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
+ 'attachTerminalQueryReplyBridge(term, gen)',
+ disableIndex
+ )
+
+ expect(initIndex).toBeGreaterThan(-1)
+ expect(disableIndex).toBeGreaterThan(initIndex)
+ expect(enableIndex).toBeGreaterThan(disableIndex)
+ })
+
+ it('suppresses replay, then forwards live queries queued behind its boundary', () => {
+ const messages: unknown[] = []
+ const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
+ gate.setGeneration(1)
+ gate.reset()
+ gate.queueBoundary(1)
+
+ gate.forward('\x1b[1;1R')
+ expect(messages).toEqual([])
+
+ queuedBoundaries[0]?.()
+ gate.forward('\x1b[2;2R')
+ expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }])
+ })
+
+ it('does not let a superseded generation reclaim reply authority', () => {
+ const messages: unknown[] = []
+ const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
+ gate.setGeneration(1)
+ gate.queueBoundary(1)
+ gate.setGeneration(2)
+ gate.reset()
+
+ queuedBoundaries[0]?.()
+ gate.forward('\x1b[1;1R')
+ expect(messages).toEqual([])
+ })
+
+ it('restores reply authority when clear discards the replay boundary', () => {
+ const messages: unknown[] = []
+ const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
+ gate.setGeneration(1)
+ gate.reset()
+ gate.queueBoundary(1)
+
+ // A clear drops all queued writes, including the replay boundary.
+ queuedBoundaries.length = 0
+ gate.resume()
+ gate.forward('\x1b[3;4R')
+
+ expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
+ const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
+ const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
+ "} else if (msg.type === 'measure')",
+ clearStart
+ )
+ expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
+ 'resumeTerminalDataReplyAuthority()'
+ )
+ })
+})
diff --git a/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts
new file mode 100644
index 00000000000..4068dcdb834
--- /dev/null
+++ b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts
@@ -0,0 +1,119 @@
+import os from 'node:os'
+import { app } from 'electron'
+import {
+ sanitizeCrashReportString,
+ type CrashReportDiagnosticBundle
+} from '../../shared/crash-reporting'
+import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
+import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
+import type { FeedbackDiagnosticBundleAttachment, FeedbackSubmitResult } from '../ipc/feedback'
+
+const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
+
+export type CrashDiagnosticBundleAttachment = {
+ readonly diagnosticBundle: CrashReportDiagnosticBundle
+ readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment
+}
+
+function formatUnknownError(error: unknown): string {
+ return sanitizeCrashReportString(error instanceof Error ? error.message : String(error))
+}
+
+function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
+ return {
+ diagnosticBundle: {
+ status: 'not_uploaded',
+ reason: 'diagnostic log upload skipped by user'
+ }
+ }
+}
+
+function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
+ const status = getDiagnosticsStatus()
+ if (!status.bundleEnabled) {
+ return {
+ diagnosticBundle: {
+ status: 'not_uploaded',
+ reason: status.disabledReason ?? 'diagnostic bundle collection is disabled'
+ }
+ }
+ }
+
+ let bundle: ReturnType
+ try {
+ bundle = collectDiagnosticBundle({
+ appVersion: app.getVersion(),
+ platform: os.platform(),
+ arch: os.arch(),
+ osRelease: os.release(),
+ orcaChannel: resolveDiagnosticOrcaChannel(),
+ // Why: Help > Report Crash is often used after relaunch, long after the
+ // default 30 minute support bundle window would miss the failure context.
+ lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
+ })
+ } catch (error) {
+ return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
+ }
+
+ return {
+ diagnosticBundle: {
+ status: 'attached',
+ bundleSubmissionId: bundle.bundleSubmissionId,
+ bytes: bundle.bytes,
+ spanCount: bundle.spanCount
+ },
+ feedbackDiagnosticBundle: {
+ bundleSubmissionId: bundle.bundleSubmissionId,
+ content: bundle.payload,
+ bytes: bundle.bytes,
+ spanCount: bundle.spanCount
+ }
+ }
+}
+
+export function prepareCrashDiagnosticBundle(
+ includeDiagnosticLogs: boolean
+): CrashDiagnosticBundleAttachment {
+ return includeDiagnosticLogs
+ ? collectCrashDiagnosticBundleAttachment()
+ : skippedCrashDiagnosticBundle()
+}
+
+export function diagnosticBundleForReportOnlyRetry(
+ attachment: CrashDiagnosticBundleAttachment
+): CrashReportDiagnosticBundle | undefined {
+ const bundle = attachment.feedbackDiagnosticBundle
+ if (!bundle) {
+ return undefined
+ }
+ return {
+ status: 'not_uploaded',
+ reason: 'diagnostic log attachment could not be sent; report retried without logs',
+ bundleSubmissionId: bundle.bundleSubmissionId,
+ bytes: bundle.bytes,
+ spanCount: bundle.spanCount
+ }
+}
+
+export function resolveSubmittedDiagnosticBundle(
+ attachment: CrashDiagnosticBundleAttachment,
+ result: FeedbackSubmitResult
+): CrashReportDiagnosticBundle {
+ const bundle = attachment.feedbackDiagnosticBundle
+ if (!bundle) {
+ return attachment.diagnosticBundle
+ }
+ const failure =
+ result.diagnosticBundleFailure ??
+ (!result.ok ? { status: result.status, error: result.error } : undefined)
+ if (!failure) {
+ return attachment.diagnosticBundle
+ }
+ return {
+ status: 'not_uploaded',
+ reason: `diagnostic log attachment failed: ${formatUnknownError(failure.error)}`,
+ bundleSubmissionId: bundle.bundleSubmissionId,
+ bytes: bundle.bytes,
+ spanCount: bundle.spanCount
+ }
+}
diff --git a/src/main/crash-reporting/crash-report-copy-text.test.ts b/src/main/crash-reporting/crash-report-copy-text.test.ts
new file mode 100644
index 00000000000..3a8ec88803c
--- /dev/null
+++ b/src/main/crash-reporting/crash-report-copy-text.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { formatCrashReportCopyText } from './crash-report-copy-text'
+
+describe('formatCrashReportCopyText', () => {
+ it('appends only sanitized report and diagnostic omission failure fields', () => {
+ const text = formatCrashReportCopyText('[Crash Report]', {
+ error: 'proxy failed at C:\\Users\\alice\\Orca',
+ diagnosticContext: {
+ status: 'not_uploaded',
+ reason: 'timeout token=super-secret-value\n- Forged field: hidden',
+ internalEndpointError: 'must-not-cross-copy-boundary'
+ },
+ internalTransportFailure: 'must-not-cross-copy-boundary'
+ })
+
+ expect(text).toContain('Submission failure:')
+ expect(text).toContain('Report error: proxy failed at [redacted-path]')
+ expect(text).toContain('Diagnostic logs not uploaded: timeout token=[redacted]')
+ expect(text).not.toContain('\n- Forged field')
+ expect(text).not.toContain('alice')
+ expect(text).not.toContain('must-not-cross-copy-boundary')
+ })
+
+ it('includes a sanitized uploaded ticket without accepting unrelated fields', () => {
+ const text = formatCrashReportCopyText('[Crash Report]', {
+ error: 'report link failed',
+ diagnosticContext: {
+ status: 'uploaded',
+ ticketId: 'ticket-123',
+ bundleSubmissionId: 'not-allow-listed'
+ }
+ })
+
+ expect(text).toContain('Diagnostic ticket uploaded but not linked: ticket-123')
+ expect(text).not.toContain('not-allow-listed')
+ })
+
+ it('leaves the report unchanged when failure context is invalid', () => {
+ expect(formatCrashReportCopyText('[Crash Report]', { error: '' })).toBe('[Crash Report]')
+ })
+})
diff --git a/src/main/crash-reporting/crash-report-copy-text.ts b/src/main/crash-reporting/crash-report-copy-text.ts
new file mode 100644
index 00000000000..681b8bd9e85
--- /dev/null
+++ b/src/main/crash-reporting/crash-report-copy-text.ts
@@ -0,0 +1,53 @@
+import {
+ sanitizeCrashReportString,
+ type CrashReportCopySubmissionFailure
+} from '../../shared/crash-reporting'
+
+function sanitizedNonEmptyString(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null
+ }
+ const sanitized = sanitizeCrashReportString(value.replace(/[\r\n]+/g, ' ')).trim()
+ return sanitized || null
+}
+
+function normalizeSubmissionFailure(value: unknown): CrashReportCopySubmissionFailure | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null
+ }
+ const record = value as Record
+ const error = sanitizedNonEmptyString(record.error)
+ if (!error) {
+ return null
+ }
+
+ const rawContext = record.diagnosticContext
+ if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) {
+ return { error }
+ }
+ const context = rawContext as Record
+ if (context.status === 'uploaded') {
+ const ticketId = sanitizedNonEmptyString(context.ticketId)
+ return ticketId ? { error, diagnosticContext: { status: 'uploaded', ticketId } } : { error }
+ }
+ if (context.status === 'not_uploaded') {
+ const reason = sanitizedNonEmptyString(context.reason)
+ return reason ? { error, diagnosticContext: { status: 'not_uploaded', reason } } : { error }
+ }
+ return { error }
+}
+
+export function formatCrashReportCopyText(baseText: string, submissionFailure: unknown): string {
+ const failure = normalizeSubmissionFailure(submissionFailure)
+ if (!failure) {
+ return baseText
+ }
+
+ const lines = [baseText, '', 'Submission failure:', `- Report error: ${failure.error}`]
+ if (failure.diagnosticContext?.status === 'uploaded') {
+ lines.push(`- Diagnostic ticket uploaded but not linked: ${failure.diagnosticContext.ticketId}`)
+ } else if (failure.diagnosticContext?.status === 'not_uploaded') {
+ lines.push(`- Diagnostic logs not uploaded: ${failure.diagnosticContext.reason}`)
+ }
+ return lines.join('\n')
+}
diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts
index 2dc9a87aa55..1a14a9fa2e6 100644
--- a/src/main/crash-reporting/crash-report-store.test.ts
+++ b/src/main/crash-reporting/crash-report-store.test.ts
@@ -1,7 +1,17 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const { grantDirAclAsyncMock } = vi.hoisted(() => ({
+ grantDirAclAsyncMock: vi.fn().mockResolvedValue(undefined)
+}))
+
+vi.mock('../win32-utils', async (importOriginal) => {
+ const actual = (await importOriginal()) as Record
+ return { ...actual, grantDirAclAsync: grantDirAclAsyncMock }
+})
+
import { CrashReportStore } from './crash-report-store'
import type { CrashReportCreateInput } from '../../shared/crash-reporting'
@@ -38,6 +48,9 @@ function input(reason = 'crashed'): CrashReportCreateInput {
}
afterEach(async () => {
+ vi.restoreAllMocks()
+ grantDirAclAsyncMock.mockReset()
+ grantDirAclAsyncMock.mockResolvedValue(undefined)
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
})
@@ -134,4 +147,72 @@ describe('CrashReportStore', () => {
await expect(store.listRecent()).resolves.toHaveLength(5)
})
+
+ it('waits for an in-flight crash write before reading the pending report', async () => {
+ const { store } = await createStore()
+
+ const recordPromise = store.record(input())
+ const pendingPromise = store.getLatestPending()
+ const report = await recordPromise
+
+ await expect(pendingPromise).resolves.toMatchObject({ id: report.id, status: 'pending' })
+ })
+
+ it('repairs the Windows userData ACL and retries a denied crash write', async () => {
+ const { store, filePath } = await createStore()
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const permissionError = Object.assign(new Error('permission denied'), { code: 'EPERM' })
+ const writeFileSpy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(permissionError)
+
+ const report = await store.record(input())
+
+ expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
+ expect(writeFileSpy).toHaveBeenCalledTimes(2)
+ await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id })
+ })
+
+ it.each(['EPERM', 'EACCES', 'EBUSY'] as const)(
+ 'recovers a pending report after a transient Windows %s read failure',
+ async (code) => {
+ const { store, filePath } = await createStore()
+ const report = await store.record(input())
+ const reloaded = new CrashReportStore(filePath)
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const readError = Object.assign(new Error('temporary read failure'), { code })
+ const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError)
+
+ await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id })
+
+ expect(readFileSpy).toHaveBeenCalledTimes(2)
+ if (code === 'EBUSY') {
+ expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
+ } else {
+ expect(grantDirAclAsyncMock).toHaveBeenCalledOnce()
+ expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
+ }
+ }
+ )
+
+ it('retries a transient Windows rename lock', async () => {
+ const { store } = await createStore()
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ const busyError = Object.assign(new Error('file busy'), { code: 'EBUSY' })
+ const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(busyError)
+
+ await expect(store.record(input())).resolves.toMatchObject({ status: 'pending' })
+
+ expect(renameSpy).toHaveBeenCalledTimes(2)
+ expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('removes its temp file after a terminal write failure', async () => {
+ const { store, filePath } = await createStore()
+ const ioError = Object.assign(new Error('disk error'), { code: 'EIO' })
+ vi.spyOn(fs, 'rename').mockRejectedValueOnce(ioError)
+
+ await expect(store.record(input())).rejects.toBe(ioError)
+
+ const entries = await fs.readdir(path.dirname(filePath))
+ expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([])
+ })
})
diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts
index e1791398e7b..35f9eb5cb48 100644
--- a/src/main/crash-reporting/crash-report-store.ts
+++ b/src/main/crash-reporting/crash-report-store.ts
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { app } from 'electron'
+import { grantDirAclAsync, isPermissionError } from '../win32-utils'
import {
formatCrashReportText,
sanitizeCrashReportBreadcrumbs,
@@ -14,6 +15,7 @@ import {
const MAX_REPORTS = 5
const RELATED_CRASH_WINDOW_MS = 5_000
+const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
type CrashReportFile = {
reports: CrashReportRecord[]
@@ -37,6 +39,46 @@ function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRe
)
}
+function isRetryableWindowsFileOperationError(error: unknown): boolean {
+ const code = (error as NodeJS.ErrnoException).code
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
+}
+
+async function wait(delayMs: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+}
+
+async function runCrashReportFileOperationWithWindowsRecovery(
+ directory: string,
+ operation: () => Promise
+): Promise {
+ let repairedAcl = false
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await operation()
+ } catch (error) {
+ if (
+ process.platform !== 'win32' ||
+ !isRetryableWindowsFileOperationError(error) ||
+ attempt >= WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS.length
+ ) {
+ throw error
+ }
+ if (!repairedAcl && isPermissionError(error)) {
+ repairedAcl = true
+ try {
+ // Why: Chromium can reset userData ACLs before startup capture or
+ // recovery, so both the crash write and next prompt must repair it.
+ await grantDirAclAsync(directory)
+ } catch {
+ // The bounded retry below still handles transient file locks.
+ }
+ }
+ await wait(WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS[attempt])
+ }
+ }
+}
+
export class CrashReportStore {
private writeChain = Promise.resolve()
@@ -135,7 +177,9 @@ export class CrashReportStore {
mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }>
): Promise {
const run = this.writeChain.then(async () => {
- const reports = await this.readReports()
+ // Why: awaiting writeChain from inside its own callback would deadlock;
+ // this writer already has exclusive ownership and can read disk directly.
+ const reports = await this.readReportsFromDisk()
const { reports: nextReports, result } = await mutate(reports)
await this.writeReports(nextReports)
return result
@@ -148,8 +192,18 @@ export class CrashReportStore {
}
private async readReports(): Promise {
+ // Why: renderer recovery can query the one-shot startup prompt while the
+ // crash write is still in flight. Wait so a successful capture is visible.
+ await this.writeChain
+ return this.readReportsFromDisk()
+ }
+
+ private async readReportsFromDisk(): Promise {
try {
- const raw = await fs.readFile(this.filePath, 'utf8')
+ const raw = await runCrashReportFileOperationWithWindowsRecovery(
+ path.dirname(this.filePath),
+ () => fs.readFile(this.filePath, 'utf8')
+ )
const parsed = JSON.parse(raw) as Partial
return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : []
} catch (error) {
@@ -161,9 +215,18 @@ export class CrashReportStore {
}
private async writeReports(reports: CrashReportRecord[]): Promise {
- await fs.mkdir(path.dirname(this.filePath), { recursive: true })
+ const directory = path.dirname(this.filePath)
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
- await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
- await fs.rename(tmpPath, this.filePath)
+ try {
+ await runCrashReportFileOperationWithWindowsRecovery(directory, async () => {
+ await fs.mkdir(directory, { recursive: true })
+ await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
+ await fs.rename(tmpPath, this.filePath)
+ })
+ } finally {
+ // Why: disk-full and terminal rename failures must not accumulate a new
+ // orphaned multi-report temp file after every crash.
+ await fs.rm(tmpPath, { force: true }).catch(() => {})
+ }
}
}
diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.test.ts b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts
new file mode 100644
index 00000000000..78c04f44d6b
--- /dev/null
+++ b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
+import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
+import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
+
+type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType }
+
+function capturingSink(): CapturingSink {
+ const records: unknown[] = []
+ const flushMock = vi.fn()
+ return {
+ records,
+ flushMock,
+ push: (record) => records.push(record),
+ flush: flushMock,
+ close: vi.fn()
+ }
+}
+
+let sink: CapturingSink
+
+beforeEach(() => {
+ sink = capturingSink()
+ setActiveSink(sink)
+ clearCrashBreadcrumbsForTest()
+})
+
+afterEach(() => {
+ _resetTracerForTests()
+ clearCrashBreadcrumbsForTest()
+})
+
+describe('recordDurableCrashBreadcrumb', () => {
+ it('records, traces, and flushes a sanitized crash breadcrumb', () => {
+ recordDurableCrashBreadcrumb('process_gone_suppressed', {
+ source: 'renderer',
+ path: 'C:\\Users\\alice\\project'
+ })
+
+ expect(getCrashBreadcrumbSnapshot()).toEqual([
+ expect.objectContaining({
+ name: 'process_gone_suppressed',
+ data: { source: 'renderer', path: '[redacted-path]' }
+ })
+ ])
+ expect(sink.records).toEqual([
+ expect.objectContaining({
+ name: 'crash.breadcrumb',
+ attributes: expect.objectContaining({
+ kind: 'crash-breadcrumb',
+ 'breadcrumb.name': 'process_gone_suppressed'
+ }),
+ exit: { _tag: 'Success' }
+ })
+ ])
+ expect(sink.flushMock).toHaveBeenCalledOnce()
+ })
+
+ it('records persistence failures as failed, flushed spans', () => {
+ recordDurableCrashBreadcrumb(
+ 'crash_report_persist_failed',
+ { errorCode: 'EPERM' },
+ 'EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca'
+ )
+
+ expect(sink.records).toEqual([
+ expect.objectContaining({
+ exit: expect.objectContaining({ _tag: 'Failure', cause: expect.stringContaining('EPERM') })
+ })
+ ])
+ expect(JSON.stringify(sink.records)).not.toContain('alice')
+ expect(sink.flushMock).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts
new file mode 100644
index 00000000000..14cca5e1bc6
--- /dev/null
+++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts
@@ -0,0 +1,33 @@
+import {
+ sanitizeCrashReportDetails,
+ sanitizeCrashReportString,
+ type CrashReportBreadcrumbData
+} from '../../shared/crash-reporting'
+import { flushActiveSink, startSpan } from '../observability/tracer'
+import { recordCrashBreadcrumb } from './crash-breadcrumb-store'
+
+export function recordDurableCrashBreadcrumb(
+ name: string,
+ data?: CrashReportBreadcrumbData,
+ failureCause?: string
+): void {
+ const sanitizedName = sanitizeCrashReportString(name)
+ const sanitizedData = data ? sanitizeCrashReportDetails(data) : undefined
+ recordCrashBreadcrumb(sanitizedName, sanitizedData)
+
+ const span = startSpan('crash.breadcrumb', {
+ attributes: {
+ kind: 'crash-breadcrumb',
+ 'breadcrumb.name': sanitizedName,
+ ...(sanitizedData ? { 'breadcrumb.data': sanitizedData } : {})
+ }
+ })
+ if (failureCause) {
+ span.fail(sanitizeCrashReportString(failureCause, 1_000))
+ } else {
+ span.end()
+ }
+ // Why: these breadcrumbs explain a missing crash record; losing one to the
+ // normal trace batching window would recreate the diagnostic blind spot.
+ flushActiveSink()
+}
diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts
index 6beebfd4926..c4a2dc714fc 100644
--- a/src/main/crash-reporting/process-gone-dedupe.test.ts
+++ b/src/main/crash-reporting/process-gone-dedupe.test.ts
@@ -11,6 +11,27 @@ describe('ProcessGoneDedupe', () => {
expect(dedupe.shouldRecord(key, 3_000)).toBe(true)
})
+ it('allows an immediate retry after a failed claim is released', () => {
+ const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
+ const claim = dedupe.tryClaim('renderer', 1_000)
+
+ expect(claim).not.toBeNull()
+ expect(dedupe.tryClaim('renderer', 1_001)).toBeNull()
+ dedupe.release(claim!)
+ expect(dedupe.tryClaim('renderer', 1_001)).not.toBeNull()
+ })
+
+ it('does not release a newer claim when an older persistence attempt fails late', () => {
+ const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
+ const oldClaim = dedupe.tryClaim('renderer', 1_000)
+ const currentClaim = dedupe.tryClaim('renderer', 3_000)
+
+ expect(oldClaim).not.toBeNull()
+ expect(currentClaim).not.toBeNull()
+ dedupe.release(oldClaim!)
+ expect(dedupe.tryClaim('renderer', 3_001)).toBeNull()
+ })
+
it('prunes stale keys outside the dedupe window', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts
index eb746a18e57..b35cccc6576 100644
--- a/src/main/crash-reporting/process-gone-dedupe.ts
+++ b/src/main/crash-reporting/process-gone-dedupe.ts
@@ -6,10 +6,19 @@ type ProcessGoneDedupeOptions = {
maxKeys?: number
}
+export type ProcessGoneDedupeClaim = {
+ readonly key: string
+}
+
+type ProcessGoneDedupeEntry = {
+ readonly recordedAt: number
+ readonly claim: ProcessGoneDedupeClaim
+}
+
export class ProcessGoneDedupe {
private readonly windowMs: number
private readonly maxKeys: number
- private readonly recentKeys = new Map()
+ private readonly recentKeys = new Map()
constructor(options: ProcessGoneDedupeOptions = {}) {
this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS
@@ -17,19 +26,33 @@ export class ProcessGoneDedupe {
}
shouldRecord(key: string, now = Date.now()): boolean {
+ return this.tryClaim(key, now) !== null
+ }
+
+ tryClaim(key: string, now = Date.now()): ProcessGoneDedupeClaim | null {
this.prune(now)
const previous = this.recentKeys.get(key)
- if (previous !== undefined && now - previous < this.windowMs) {
- return false
+ if (previous && now - previous.recordedAt < this.windowMs) {
+ return null
}
+ const claim = { key }
// Why: process-gone tuples come from Electron and can vary by exit code;
// keep the short dedupe window without retaining stale tuples forever.
this.recentKeys.delete(key)
- this.recentKeys.set(key, now)
+ this.recentKeys.set(key, { recordedAt: now, claim })
this.prune(now)
- return true
+ return claim
+ }
+
+ release(claim: ProcessGoneDedupeClaim): void {
+ const current = this.recentKeys.get(claim.key)
+ // Why: an old failed write must not erase a newer claim for the same
+ // renderer after the dedupe window expires or bounded entries are evicted.
+ if (current?.claim === claim) {
+ this.recentKeys.delete(claim.key)
+ }
}
get size(): number {
@@ -37,8 +60,8 @@ export class ProcessGoneDedupe {
}
private prune(now: number): void {
- for (const [key, recordedAt] of this.recentKeys) {
- if (now - recordedAt >= this.windowMs) {
+ for (const [key, entry] of this.recentKeys) {
+ if (now - entry.recordedAt >= this.windowMs) {
this.recentKeys.delete(key)
}
}
diff --git a/src/main/crash-reporting/process-gone-diagnostics.test.ts b/src/main/crash-reporting/process-gone-diagnostics.test.ts
index 769ed618766..e179cec86f3 100644
--- a/src/main/crash-reporting/process-gone-diagnostics.test.ts
+++ b/src/main/crash-reporting/process-gone-diagnostics.test.ts
@@ -71,6 +71,7 @@ describe('process gone diagnostics', () => {
processType: 'Utility',
reason: 'killed',
exitCode: 1,
+ expectedTeardown: 'app-shutdown',
details: {
name: 'Network Service',
serviceName: 'network.mojom.NetworkService',
@@ -82,6 +83,7 @@ describe('process gone diagnostics', () => {
processType: 'Utility',
reason: 'killed',
exitCode: 1,
+ expectedTeardown: 'app-shutdown',
name: 'Network Service',
serviceName: 'network.mojom.NetworkService'
})
diff --git a/src/main/crash-reporting/process-gone-diagnostics.ts b/src/main/crash-reporting/process-gone-diagnostics.ts
index 42c8cad3125..fd93c48b88a 100644
--- a/src/main/crash-reporting/process-gone-diagnostics.ts
+++ b/src/main/crash-reporting/process-gone-diagnostics.ts
@@ -4,6 +4,7 @@ import {
type CrashReportBreadcrumbData,
type CrashReportDetailValue
} from '../../shared/crash-reporting'
+import type { ExpectedTeardownScope } from './process-gone-classification'
type ProcessMetricLike = {
pid?: unknown
@@ -124,19 +125,22 @@ export function buildSuppressedProcessGoneBreadcrumbData({
processType,
reason,
exitCode,
+ expectedTeardown,
details
}: {
source: 'renderer' | 'child'
processType: string
reason: string
exitCode: number | null
+ expectedTeardown: ExpectedTeardownScope
details: Record
}): CrashReportBreadcrumbData {
const breadcrumb: CrashReportBreadcrumbData = {
source,
processType,
reason,
- exitCode
+ exitCode,
+ expectedTeardown
}
const name = safeString(details.name)
if (name) {
diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts
new file mode 100644
index 00000000000..94a67982fa8
--- /dev/null
+++ b/src/main/crash-reporting/process-gone-recorder.test.ts
@@ -0,0 +1,213 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => ({
+ app: {
+ getVersion: () => '1.2.3-test',
+ getAppMetrics: () => []
+ }
+}))
+
+import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
+import { ProcessGoneDedupe } from './process-gone-dedupe'
+import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
+import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
+
+type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType }
+
+function capturingSink(): CapturingSink {
+ const records: unknown[] = []
+ const flushMock = vi.fn()
+ return {
+ records,
+ flushMock,
+ push: (record) => records.push(record),
+ flush: flushMock,
+ close: vi.fn()
+ }
+}
+
+function event(overrides: Partial = {}): ProcessGoneCrashEvent {
+ return {
+ source: 'renderer',
+ processType: 'renderer',
+ reason: 'crashed',
+ exitCode: 5,
+ expectedTeardown: 'none',
+ details: { processType: 'renderer' },
+ ...overrides
+ }
+}
+
+let sink: CapturingSink
+
+beforeEach(() => {
+ sink = capturingSink()
+ setActiveSink(sink)
+ clearCrashBreadcrumbsForTest()
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ _resetTracerForTests()
+ clearCrashBreadcrumbsForTest()
+})
+
+describe('recordProcessGoneCrash', () => {
+ it('durably records when the crash report store is unavailable', () => {
+ recordProcessGoneCrash(null, event(), new ProcessGoneDedupe())
+
+ expect(getCrashBreadcrumbSnapshot()).toEqual([
+ expect.objectContaining({
+ name: 'crash_report_store_unavailable',
+ data: expect.objectContaining({
+ source: 'renderer',
+ expectedTeardown: 'none'
+ })
+ })
+ ])
+ expect(sink.records).toEqual([
+ expect.objectContaining({
+ name: 'crash.breadcrumb',
+ exit: expect.objectContaining({ _tag: 'Failure' })
+ })
+ ])
+ expect(sink.flushMock).toHaveBeenCalledOnce()
+ })
+
+ it('durably records why an expected renderer teardown was suppressed', () => {
+ const record = vi.fn()
+
+ recordProcessGoneCrash(
+ { record } as never,
+ event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload' }),
+ new ProcessGoneDedupe()
+ )
+
+ expect(record).not.toHaveBeenCalled()
+ expect(getCrashBreadcrumbSnapshot()).toEqual([
+ expect.objectContaining({
+ name: 'process_gone_suppressed',
+ data: expect.objectContaining({ expectedTeardown: 'renderer-reload' })
+ })
+ ])
+ expect(sink.records).toEqual([
+ expect.objectContaining({
+ name: 'crash.breadcrumb',
+ attributes: expect.objectContaining({
+ 'breadcrumb.name': 'process_gone_suppressed'
+ })
+ })
+ ])
+ expect(sink.flushMock).toHaveBeenCalledOnce()
+ })
+
+ it('persists a report and flushes the process-gone trace before recovery', async () => {
+ const record = vi.fn().mockResolvedValue({ id: 'report-1' })
+
+ recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
+
+ await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
+ expect(record).toHaveBeenCalledWith(
+ expect.objectContaining({ source: 'renderer', reason: 'crashed', exitCode: 5 })
+ )
+ expect(sink.records).toEqual([
+ expect.objectContaining({
+ name: 'electron.process_gone',
+ exit: expect.objectContaining({ _tag: 'Failure' })
+ })
+ ])
+ expect(sink.flushMock).toHaveBeenCalledOnce()
+ })
+
+ it('still persists the report when the forced trace flush fails', async () => {
+ const record = vi.fn().mockResolvedValue({ id: 'report-1' })
+ sink.flushMock.mockImplementation(() => {
+ throw new Error('trace disk unavailable')
+ })
+
+ expect(() =>
+ recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
+ ).not.toThrow()
+ await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
+ })
+
+ it('still persists the report when the trace sink handoff fails', async () => {
+ const record = vi.fn().mockResolvedValue({ id: 'report-1' })
+ sink.push = () => {
+ throw new Error('trace rotation failed')
+ }
+
+ expect(() =>
+ recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
+ ).not.toThrow()
+ await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
+ })
+
+ it('durably records a sanitized crash-report persistence failure', async () => {
+ const persistError = Object.assign(
+ new Error('EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca\\crash-reports.json'),
+ { code: 'EPERM' }
+ )
+ const record = vi.fn().mockRejectedValue(persistError)
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
+
+ await vi.waitFor(() => {
+ expect(getCrashBreadcrumbSnapshot()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'crash_report_persist_failed',
+ data: expect.objectContaining({ errorCode: 'EPERM' })
+ })
+ ])
+ )
+ })
+ expect(sink.records).toHaveLength(2)
+ expect(sink.records[1]).toEqual(
+ expect.objectContaining({
+ name: 'crash.breadcrumb',
+ exit: expect.objectContaining({ _tag: 'Failure' })
+ })
+ )
+ expect(JSON.stringify(sink.records)).not.toContain('alice')
+ expect(sink.flushMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps null persistence rejections inside the fail-open diagnostic path', async () => {
+ const record = vi.fn().mockRejectedValue(null)
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
+
+ await vi.waitFor(() =>
+ expect(getCrashBreadcrumbSnapshot()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'crash_report_persist_failed',
+ data: expect.objectContaining({ errorName: 'object', errorMessage: 'null' })
+ })
+ ])
+ )
+ )
+ })
+
+ it('allows the same renderer crash to retry after persistence fails', async () => {
+ const record = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('disk unavailable'))
+ .mockResolvedValueOnce({ id: 'report-2' })
+ const dedupe = new ProcessGoneDedupe()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ recordProcessGoneCrash({ record } as never, event(), dedupe)
+ await vi.waitFor(() =>
+ expect(getCrashBreadcrumbSnapshot()).toEqual(
+ expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
+ )
+ )
+ recordProcessGoneCrash({ record } as never, event(), dedupe)
+
+ await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2))
+ })
+})
diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts
new file mode 100644
index 00000000000..daec429ae3e
--- /dev/null
+++ b/src/main/crash-reporting/process-gone-recorder.ts
@@ -0,0 +1,137 @@
+import os from 'node:os'
+import { app } from 'electron'
+import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting'
+import type { CrashReportStore } from './crash-report-store'
+import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
+import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
+import {
+ shouldRecordProcessGoneCrash,
+ type ExpectedTeardownScope,
+ type ProcessGoneSource
+} from './process-gone-classification'
+import {
+ buildProcessGoneCrashDetails,
+ buildSuppressedProcessGoneBreadcrumbData
+} from './process-gone-diagnostics'
+import {
+ getProcessGoneDedupeKey,
+ processGoneDedupe,
+ type ProcessGoneDedupe
+} from './process-gone-dedupe'
+import { flushActiveSink, startSpan } from '../observability/tracer'
+
+export type ProcessGoneCrashEvent = {
+ source: ProcessGoneSource
+ processType: string
+ reason: string
+ exitCode: number | null
+ expectedTeardown: ExpectedTeardownScope
+ details: Record
+}
+
+type CrashReportRecorderStore = Pick
+
+function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) {
+ return buildSuppressedProcessGoneBreadcrumbData(event)
+}
+
+function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) {
+ const errorCode =
+ typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
+ ? error.code
+ : undefined
+ return {
+ ...processGoneBreadcrumbData(event),
+ errorName: error instanceof Error ? error.name : typeof error,
+ errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)),
+ ...(errorCode ? { errorCode } : {})
+ }
+}
+
+export function recordProcessGoneCrash(
+ store: CrashReportRecorderStore | null,
+ event: ProcessGoneCrashEvent,
+ dedupe: ProcessGoneDedupe = processGoneDedupe
+): void {
+ if (!isCrashReportReason(event.reason)) {
+ return
+ }
+ if (
+ !shouldRecordProcessGoneCrash({
+ source: event.source,
+ processType: event.processType,
+ serviceName:
+ typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined,
+ reason: event.reason,
+ exitCode: event.exitCode,
+ expectedTeardown: event.expectedTeardown
+ })
+ ) {
+ recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event))
+ return
+ }
+ if (!store) {
+ recordDurableCrashBreadcrumb(
+ 'crash_report_store_unavailable',
+ processGoneBreadcrumbData(event),
+ 'Crash report store unavailable'
+ )
+ return
+ }
+
+ const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode)
+ const claim = dedupe.tryClaim(key)
+ if (!claim) {
+ return
+ }
+ const crashDetails = buildProcessGoneCrashDetails(event.details)
+ const breadcrumbs = getCrashBreadcrumbSnapshot()
+ const span = startSpan('electron.process_gone', {
+ attributes: {
+ 'crash.source': event.source,
+ 'crash.process_type': event.processType,
+ 'crash.reason': event.reason,
+ ...(event.exitCode !== null ? { 'crash.exit_code': event.exitCode } : {}),
+ 'app.version': app.getVersion(),
+ platform: process.platform,
+ osRelease: os.release(),
+ arch: process.arch,
+ electronVersion: process.versions.electron,
+ chromeVersion: process.versions.chrome,
+ details: crashDetails,
+ breadcrumbs
+ }
+ })
+ // Why: a renderer crash can be followed by another process exit before the
+ // trace batch window closes, so make the primary signal durable immediately.
+ span.fail(
+ `${event.source} process gone: ${event.processType} ${event.reason} (${event.exitCode ?? 'unknown'})`
+ )
+ flushActiveSink()
+
+ void store
+ .record({
+ source: event.source,
+ processType: event.processType,
+ reason: event.reason,
+ exitCode: event.exitCode,
+ appVersion: app.getVersion(),
+ platform: process.platform,
+ osRelease: os.release(),
+ arch: process.arch,
+ electronVersion: process.versions.electron ?? 'unknown',
+ chromeVersion: process.versions.chrome ?? 'unknown',
+ details: crashDetails,
+ breadcrumbs
+ })
+ .catch((error) => {
+ dedupe.release(claim)
+ console.error('[crash-reporting] Failed to persist crash report:', error)
+ const data = persistFailureData(event, error)
+ recordDurableCrashBreadcrumb(
+ 'crash_report_persist_failed',
+ data,
+ `${String(data.errorName)}: ${String(data.errorMessage)}`
+ )
+ })
+}
diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts
index 908ef9a68b2..ca21dd5e58b 100644
--- a/src/main/daemon/pty-subprocess.test.ts
+++ b/src/main/daemon/pty-subprocess.test.ts
@@ -2291,6 +2291,7 @@ describe('createPtySubprocess', () => {
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
env: {
ORCA_TERMINAL_HANDLE: 'term_wsl',
+ ORCA_HERMES_STARTUP_QUERY: 'line one\nline two',
WSLENV: 'FOO/u'
}
})
@@ -2317,7 +2318,12 @@ describe('createPtySubprocess', () => {
// Why: the daemon inherits optional agent-hook env in development. This
// test owns only the terminal handle and Powerlevel10k WSLENV contract.
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
- expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
+ expect.arrayContaining([
+ 'FOO/u',
+ 'ORCA_TERMINAL_HANDLE/u',
+ 'ORCA_HERMES_STARTUP_QUERY',
+ POWERLEVEL10K_WIZARD_DISABLE_ENV
+ ])
)
})
diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts
index 5d36ebe9d69..6a285301406 100644
--- a/src/main/daemon/pty-subprocess.ts
+++ b/src/main/daemon/pty-subprocess.ts
@@ -56,6 +56,7 @@ import { isShellProcess } from '../../shared/shell-process-detection'
import { parsePtySessionId } from './pty-session-id'
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
+import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -717,6 +718,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// through Windows wsl.exe; non-default env vars need WSLENV import.
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
+ if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
+ // Why: the startup wrapper expands this only inside WSL; wsl.exe
+ // otherwise drops custom Windows environment variables.
+ addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
+ }
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
diff --git a/src/main/git/repo-default-base-timeout.test.ts b/src/main/git/repo-default-base-timeout.test.ts
new file mode 100644
index 00000000000..b7c1518bc77
--- /dev/null
+++ b/src/main/git/repo-default-base-timeout.test.ts
@@ -0,0 +1,46 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { gitExecFileAsyncMock } = vi.hoisted(() => ({
+ gitExecFileAsyncMock: vi.fn()
+}))
+
+vi.mock('./runner', () => ({
+ gitExecFileAsync: gitExecFileAsyncMock,
+ gitExecFileSync: vi.fn()
+}))
+
+import { getBaseRefDefault } from './repo'
+
+describe('getBaseRefDefault async subprocess bounds', () => {
+ beforeEach(() => {
+ gitExecFileAsyncMock.mockReset()
+ })
+
+ it('bounds every local probe and degrades a timeout to no default', async () => {
+ gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
+
+ await expect(getBaseRefDefault('/repo')).resolves.toBeNull()
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalled()
+ for (const [, options] of gitExecFileAsyncMock.mock.calls) {
+ expect(options).toEqual({ cwd: '/repo', timeout: 15_000 })
+ }
+ })
+
+ it('preserves the same timeout when routing probes through WSL', async () => {
+ gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
+
+ await expect(
+ getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' })
+ ).resolves.toBeNull()
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalled()
+ for (const [, options] of gitExecFileAsyncMock.mock.calls) {
+ expect(options).toEqual({
+ cwd: '\\\\wsl.localhost\\Ubuntu\\repo',
+ timeout: 15_000,
+ wslDistro: 'Ubuntu'
+ })
+ }
+ })
+})
diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts
index f649017cdeb..fa1c23ebf0d 100644
--- a/src/main/git/repo.ts
+++ b/src/main/git/repo.ts
@@ -15,6 +15,13 @@ type LocalGitExecOptions = {
wslDistro?: string
}
+type LocalDefaultBaseRefGitOptions = {
+ cwd: string
+ wslDistro?: string
+}
+
+const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
+
type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate'
type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' }
@@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise hasGitRefViaExec(exec, ref))
}
+export function resolveDefaultBaseRefWithLocalGit(
+ options: LocalDefaultBaseRefGitOptions
+): Promise {
+ return resolveDefaultBaseRefViaExec((argv) =>
+ gitExecFileAsync(argv, {
+ ...options,
+ // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound.
+ timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS
+ })
+ )
+}
+
async function getDefaultBaseRefAsync(
path: string,
options: LocalGitExecOptions = {}
): Promise {
- return resolveDefaultBaseRefViaExec((argv) =>
- gitExecFileAsync(argv, gitExecOptions(path, options))
- )
+ return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options))
}
/**
diff --git a/src/main/index.ts b/src/main/index.ts
index 975e3a4596b..27e9aee2aa4 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -26,7 +26,6 @@ import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
-import { startSpan } from './observability/tracer'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
@@ -159,28 +158,21 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import {
- getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-reporting/crash-breadcrumb-store'
import { CrashReportStore } from './crash-reporting/crash-report-store'
import {
- shouldRecordProcessGoneCrash,
shouldRecoverRendererAfterProcessGone,
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
-import {
- buildProcessGoneCrashDetails,
- buildSuppressedProcessGoneBreadcrumbData
-} from './crash-reporting/process-gone-diagnostics'
-import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe'
+import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
-import { isCrashReportReason } from '../shared/crash-reporting'
import {
getSyntheticAgentTitleProfile,
shouldDriveSyntheticAgentTitleFromHook,
@@ -833,14 +825,6 @@ function openMainWindow(): BrowserWindow {
webContentsId
)
},
- shouldRecordRendererCrash: (details, webContentsId) =>
- shouldRecordProcessGoneCrash({
- source: 'renderer',
- processType: 'renderer',
- reason: details.reason,
- exitCode: details.exitCode ?? null,
- expectedTeardown: getExpectedTeardownScope(webContentsId)
- }),
shouldRecoverRenderer: (details, webContentsId) =>
shouldRecoverRendererAfterProcessGone({
reason: details.reason,
@@ -1229,75 +1213,14 @@ function recordProcessGoneCrash(
details: Record,
webContentsId?: number
): void {
- if (!crashReports || !isCrashReportReason(reason)) {
- return
- }
- if (
- !shouldRecordProcessGoneCrash({
- source,
- processType,
- serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined,
- reason,
- exitCode,
- expectedTeardown: getExpectedTeardownScope(webContentsId)
- })
- ) {
- recordCrashBreadcrumb(
- 'process_gone_suppressed',
- buildSuppressedProcessGoneBreadcrumbData({
- source,
- processType,
- reason,
- exitCode,
- details
- })
- )
- return
- }
- const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
- if (!processGoneDedupe.shouldRecord(key)) {
- return
- }
- const crashDetails = buildProcessGoneCrashDetails(details)
- const span = startSpan('electron.process_gone', {
- attributes: {
- 'crash.source': source,
- 'crash.process_type': processType,
- 'crash.reason': reason,
- ...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}),
- 'app.version': app.getVersion(),
- platform: process.platform,
- osRelease: os.release(),
- arch: process.arch,
- electronVersion: process.versions.electron,
- chromeVersion: process.versions.chrome,
- details: crashDetails,
- breadcrumbs: getCrashBreadcrumbSnapshot()
- }
+ recordProcessGoneCrashEvent(crashReports, {
+ source,
+ processType,
+ reason,
+ exitCode,
+ expectedTeardown: getExpectedTeardownScope(webContentsId),
+ details
})
- // Why: renderer/child crashes belong in the local trace lane so the
- // diagnostic bundle has the same process-gone signal as the startup prompt.
- span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`)
- void crashReports
- .record({
- source,
- processType,
- reason,
- exitCode,
- appVersion: app.getVersion(),
- platform: process.platform,
- osRelease: os.release(),
- arch: process.arch,
- electronVersion: process.versions.electron,
- chromeVersion: process.versions.chrome,
- details: crashDetails,
- // Why: breadcrumbs stay memory-only during normal operation. Persist a
- // snapshot only after Electron reports a crash-like process exit.
- breadcrumbs: getCrashBreadcrumbSnapshot()
- })
- .catch((error) => {
- console.error('[crash-reporting] Failed to persist crash report:', error)
- })
}
function shutdownWatchersOnce(): Promise {
diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts
index 04432899abb..2c28cfd6c77 100644
--- a/src/main/ipc/crash-reporting.test.ts
+++ b/src/main/ipc/crash-reporting.test.ts
@@ -185,6 +185,42 @@ describe('registerCrashReportingHandlers', () => {
expect(listRecent).not.toHaveBeenCalled()
})
+ it('copies sanitized submission and diagnostic omission failures for a captured report', async () => {
+ const pending = report('pending', 'crash-copy-failure')
+ registerCrashReportingHandlers({
+ getById: vi.fn(async () => pending),
+ dismiss: vi.fn(),
+ markSent: vi.fn(),
+ markDismissedSent: vi.fn(),
+ listRecent: vi.fn(async () => [pending]),
+ record: vi.fn(),
+ formatDiagnosticText: vi.fn()
+ } as never)
+
+ const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
+ reportId: pending.id,
+ notes: 'current notes',
+ submissionFailure: {
+ error: 'fallback failed at C:\\Users\\alice\\Orca',
+ diagnosticContext: {
+ status: 'not_uploaded',
+ reason: 'attachment token=super-secret-value',
+ internalEndpointError: 'must-not-cross-copy-boundary'
+ },
+ diagnosticBundleFailure: 'must-not-cross-copy-boundary'
+ }
+ })
+
+ expect(result).toEqual({ ok: true })
+ const copiedText = String(clipboardWriteTextMock.mock.calls[0]?.[0])
+ expect(copiedText).toContain('Report ID: crash-copy-failure')
+ expect(copiedText).toContain('Submission failure:')
+ expect(copiedText).toContain('Report error: fallback failed at [redacted-path]')
+ expect(copiedText).toContain('Diagnostic logs not uploaded: attachment token=[redacted]')
+ expect(copiedText).not.toContain('alice')
+ expect(copiedText).not.toContain('must-not-cross-copy-boundary')
+ })
+
it('returns dismissed unsent reports for the manual Help menu entry', async () => {
const dismissed = report('dismissed', 'crash-help-menu')
registerCrashReportingHandlers({
@@ -244,7 +280,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
- }
+ },
+ feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(markSent).toHaveBeenCalledWith(pending.id)
})
@@ -294,7 +331,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
- }
+ },
+ feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(submitFeedbackMock).toHaveBeenCalledWith(
expect.objectContaining({ feedback: expect.stringContaining('Status: attached') })
@@ -347,6 +385,52 @@ describe('registerCrashReportingHandlers', () => {
)
})
+ it('marks the report sent when transport retries successfully without diagnostic logs', async () => {
+ const pending = report('pending', 'crash-degraded')
+ const sent = report('sent', pending.id)
+ const markSent = vi.fn(async () => sent)
+ submitFeedbackMock.mockResolvedValueOnce({
+ ok: true,
+ diagnosticBundleFailure: { status: 413, error: 'status 413' }
+ })
+ registerCrashReportingHandlers({
+ getById: vi.fn(async () => pending),
+ dismiss: vi.fn(),
+ markSent,
+ markDismissedSent: vi.fn(),
+ listRecent: vi.fn(async () => [pending]),
+ record: vi.fn(),
+ formatDiagnosticText: vi.fn()
+ } as never)
+
+ const result = await handlers.get('crashReports:submit')?.(null, {
+ reportId: pending.id,
+ notes: 'manual report',
+ submitAnonymously: true,
+ githubLogin: null,
+ githubEmail: null
+ })
+
+ expect(result).toEqual({
+ ok: true,
+ report: sent,
+ diagnosticBundle: {
+ status: 'not_uploaded',
+ reason: 'diagnostic log attachment failed: status 413',
+ bundleSubmissionId: 'bundleabcdefghijklmnop',
+ bytes: 25,
+ spanCount: 1
+ }
+ })
+ expect(markSent).toHaveBeenCalledWith(pending.id)
+ expect(submitFeedbackMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ feedback: expect.stringContaining('Status: attached'),
+ feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
+ })
+ )
+ })
+
it('submits the crash report without logs when the user excludes diagnostic logs', async () => {
registerCrashReportingHandlers({
getById: vi.fn(async () => null),
@@ -459,7 +543,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
- }
+ },
+ feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id)
})
@@ -492,8 +577,9 @@ describe('registerCrashReportingHandlers', () => {
const markSent = vi.fn()
submitFeedbackMock.mockResolvedValue({
ok: false,
- status: 500,
- error: 'status 500'
+ status: null,
+ error: 'report-only network failed',
+ diagnosticBundleFailure: { status: 500, error: 'status 500' }
})
registerCrashReportingHandlers({
getById: vi.fn(async () => pending),
@@ -514,10 +600,18 @@ describe('registerCrashReportingHandlers', () => {
expect(result).toEqual({
ok: false,
- status: 500,
- error: 'status 500',
- report: pending
+ status: null,
+ error: 'report-only network failed',
+ report: pending,
+ diagnosticBundle: {
+ status: 'not_uploaded',
+ reason: 'diagnostic log attachment failed: status 500',
+ bundleSubmissionId: 'bundleabcdefghijklmnop',
+ bytes: 25,
+ spanCount: 1
+ }
})
+ expect(result).not.toHaveProperty('diagnosticBundleFailure')
expect(submitFeedbackMock).toHaveBeenCalledWith(
expect.objectContaining({
diagnosticBundle: {
diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts
index 7d60da1799f..ac424d834a8 100644
--- a/src/main/ipc/crash-reporting.ts
+++ b/src/main/ipc/crash-reporting.ts
@@ -4,6 +4,7 @@ import os from 'node:os'
import { app, clipboard, ipcMain } from 'electron'
import {
type CrashReportBreadcrumbData,
+ type CrashReportCopyDiagnosticsArgs,
type CrashReportDiagnosticBundle,
type ReactErrorBoundaryReportArgs,
type ReactErrorBoundaryReportResult,
@@ -20,14 +21,17 @@ import {
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from '../crash-reporting/crash-breadcrumb-store'
-import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
-import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
import { startSpan } from '../observability/tracer'
-import type { FeedbackDiagnosticBundleAttachment } from './feedback'
+import {
+ diagnosticBundleForReportOnlyRetry,
+ prepareCrashDiagnosticBundle,
+ resolveSubmittedDiagnosticBundle
+} from '../crash-reporting/crash-feedback-diagnostic-bundle'
import {
assertClipboardTextWriteWithinLimit,
isClipboardTextWriteTooLargeError
} from '../../shared/clipboard-text'
+import { formatCrashReportCopyText } from '../crash-reporting/crash-report-copy-text'
const inFlightSubmissions = new Set()
const submittedReportIds = new Set()
@@ -37,7 +41,6 @@ const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000
const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2
const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256
const MAX_SUBMITTED_REPORT_IDS = 256
-const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
const REACT_ERROR_BOUNDARY_SURFACES = new Set([
'app-root',
@@ -52,11 +55,6 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set
- try {
- bundle = collectDiagnosticBundle({
- appVersion: app.getVersion(),
- platform: os.platform(),
- arch: os.arch(),
- osRelease: os.release(),
- orcaChannel: resolveDiagnosticOrcaChannel(),
- // Why: Help > Report Crash is often used after relaunch, long after the
- // default 30 minute support bundle window would miss the failure context.
- lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
- })
- } catch (error) {
- return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
- }
-
- return {
- diagnosticBundle: {
- status: 'attached',
- bundleSubmissionId: bundle.bundleSubmissionId,
- bytes: bundle.bytes,
- spanCount: bundle.spanCount
- },
- feedbackDiagnosticBundle: {
- bundleSubmissionId: bundle.bundleSubmissionId,
- content: bundle.payload,
- bytes: bundle.bytes,
- spanCount: bundle.spanCount
- }
- }
-}
-
export function registerCrashReportingHandlers(store: CrashReportStore): void {
ipcMain.removeHandler('crashReports:getLatestPending')
ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store))
@@ -412,15 +354,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
ipcMain.removeHandler('crashReports:copyLatestDiagnostics')
ipcMain.handle(
'crashReports:copyLatestDiagnostics',
- async (_event, args?: { reportId?: string; notes?: string }) => {
+ async (_event, args?: CrashReportCopyDiagnosticsArgs) => {
const report = await getRequestedCrashReport(store, args)
- if (!report) {
- clipboard.writeText(buildUncapturedCrashReportText(args?.notes))
- return { ok: true as const }
- }
+ const baseText = report
+ ? formatCrashReportText(report, args?.notes)
+ : buildUncapturedCrashReportText(args?.notes)
try {
clipboard.writeText(
- assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes))
+ assertClipboardTextWriteWithinLimit(
+ formatCrashReportCopyText(baseText, args?.submissionFailure)
+ )
)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
@@ -448,11 +391,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
async (_event, args: CrashReportSubmitArgs): Promise => {
const report = await getRequestedCrashReport(store, args)
if (!report) {
- const diagnosticUpload =
- args.includeDiagnosticLogs === false
- ? skippedCrashDiagnosticBundle()
- : collectCrashDiagnosticBundleAttachment()
+ const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
const diagnosticBundle = diagnosticUpload.diagnosticBundle
+ const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
const result = await submitFeedback({
feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle),
submissionType: 'crash',
@@ -460,14 +401,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
githubLogin: args.githubLogin,
githubEmail: args.githubEmail,
...(diagnosticUpload.feedbackDiagnosticBundle
- ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
+ ? {
+ diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
+ feedbackWithoutDiagnosticBundle: buildUncapturedCrashReportText(
+ args.notes,
+ reportOnlyDiagnosticBundle
+ )
+ }
: {})
})
+ const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
return result.ok
- ? { ok: true, report: null, diagnosticBundle }
+ ? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle }
: {
- ...result,
- report: null
+ // Why: the transport-only attachment failure may contain raw
+ // endpoint detail; only its sanitized bundle reason crosses IPC.
+ ok: false,
+ status: result.status,
+ error: result.error,
+ report: null,
+ diagnosticBundle: submittedDiagnosticBundle
}
}
const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed')
@@ -491,11 +444,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
inFlightSubmissions.add(report.id)
try {
- const diagnosticUpload =
- args.includeDiagnosticLogs === false
- ? skippedCrashDiagnosticBundle()
- : collectCrashDiagnosticBundleAttachment()
+ const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
const diagnosticBundle = diagnosticUpload.diagnosticBundle
+ const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
const result = await submitFeedback({
feedback: formatCrashReportText(report, args.notes, diagnosticBundle),
submissionType: 'crash',
@@ -503,13 +454,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
githubLogin: args.githubLogin,
githubEmail: args.githubEmail,
...(diagnosticUpload.feedbackDiagnosticBundle
- ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
+ ? {
+ diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
+ feedbackWithoutDiagnosticBundle: formatCrashReportText(
+ report,
+ args.notes,
+ reportOnlyDiagnosticBundle
+ )
+ }
: {})
})
+ const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
if (!result.ok) {
return {
- ...result,
- report
+ // Why: keep the renderer contract allow-listed instead of leaking
+ // the transport's internal diagnosticBundleFailure object.
+ ok: false,
+ status: result.status,
+ error: result.error,
+ report,
+ diagnosticBundle: submittedDiagnosticBundle
}
}
rememberSubmittedReportId(report.id)
@@ -518,21 +482,37 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
// Why: startup prompts are dismissed before the user can send from
// the still-open dialog, so successful uploads must update storage.
const sent = await store.markDismissedSent(report.id)
- return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
+ return {
+ ok: true,
+ report: sent ?? { ...report, status: 'sent' },
+ diagnosticBundle: submittedDiagnosticBundle
+ }
} catch (error) {
console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error)
- return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
+ return {
+ ok: true,
+ report: { ...report, status: 'sent' },
+ diagnosticBundle: submittedDiagnosticBundle
+ }
}
}
try {
const sent = await store.markSent(report.id)
- return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
+ return {
+ ok: true,
+ report: sent ?? { ...report, status: 'sent' },
+ diagnosticBundle: submittedDiagnosticBundle
+ }
} catch (error) {
// Why: the upstream submission already succeeded. A local persistence
// failure must not present as upload failure or invite duplicate sends
// during this app session.
console.error('[crash-reporting] Failed to mark crash report sent:', error)
- return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
+ return {
+ ok: true,
+ report: { ...report, status: 'sent' },
+ diagnosticBundle: submittedDiagnosticBundle
+ }
}
} finally {
inFlightSubmissions.delete(report.id)
diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts
index 1d8c3053885..52de8251d1b 100644
--- a/src/main/ipc/feedback.test.ts
+++ b/src/main/ipc/feedback.test.ts
@@ -22,9 +22,34 @@ function okResponse(): Response {
return { ok: true, status: 200 } as unknown as Response
}
-function postedBody(): Record {
- const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
- return JSON.parse(String(init?.body)) as Record
+function errorResponse(status: number): Response {
+ return { ok: false, status } as unknown as Response
+}
+
+function requestInit(callIndex = 0): RequestInit {
+ return fetchMock.mock.calls[callIndex]?.[1] as RequestInit
+}
+
+function postedBody(callIndex = 0): Record {
+ return JSON.parse(String(requestInit(callIndex).body)) as Record
+}
+
+function diagnosticSubmitArgs(): Parameters[0] {
+ return {
+ feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached',
+ feedbackWithoutDiagnosticBundle:
+ '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
+ submissionType: 'crash',
+ submitAnonymously: true,
+ githubLogin: null,
+ githubEmail: null,
+ diagnosticBundle: {
+ bundleSubmissionId: 'bundleabcdefghijklmnop',
+ content: '{"type":"bundle-header"}\n',
+ bytes: 25,
+ spanCount: 1
+ }
+ }
}
describe('submitFeedback', () => {
@@ -139,6 +164,118 @@ describe('submitFeedback', () => {
expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle')
})
+ it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => {
+ fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse())
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: true,
+ diagnosticBundleFailure: { status: 413, error: 'status 413' }
+ })
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
+ expect(requestInit(0).body).toBeInstanceOf(FormData)
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
+ expect(requestInit(1).headers).toEqual({
+ 'Content-Type': 'application/json'
+ })
+ expect(postedBody(1)).toMatchObject({
+ feedback:
+ '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
+ submissionType: 'crash'
+ })
+ expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
+ })
+
+ it('retries a diagnostic attachment server error as report-only JSON on the fallback API', async () => {
+ fetchMock.mockResolvedValueOnce(errorResponse(500)).mockResolvedValueOnce(okResponse())
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: true,
+ diagnosticBundleFailure: { status: 500, error: 'status 500' }
+ })
+
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
+ expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' })
+ expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
+ })
+
+ it('retries a diagnostic attachment network error as report-only JSON on the fallback API', async () => {
+ fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
+ fetchMock.mockResolvedValueOnce(okResponse())
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: true,
+ diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
+ })
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
+ expect(requestInit(1).body).not.toBeInstanceOf(FormData)
+ expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
+ })
+
+ it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => {
+ vi.useFakeTimers()
+ fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text')))
+ })
+ })
+ fetchMock.mockResolvedValueOnce(okResponse())
+ const result = submitFeedback(diagnosticSubmitArgs())
+
+ await vi.advanceTimersByTimeAsync(59_999)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(1)
+
+ await expect(result).resolves.toEqual({
+ ok: true,
+ diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' }
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
+ expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
+ })
+
+ it('retries a proxy-rejected diagnostic attachment as website JSON', async () => {
+ fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse())
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: true,
+ diagnosticBundleFailure: { status: 403, error: 'status 403' }
+ })
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
+ expect(requestInit(1).body).not.toBeInstanceOf(FormData)
+ })
+
+ it.each([401, 409, 429])(
+ 'does not retry a diagnostic attachment rejected with status %s',
+ async (status) => {
+ fetchMock.mockResolvedValueOnce(errorResponse(status))
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: false,
+ status,
+ error: `status ${status}`
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it('preserves attachment and report-only failures when the degraded retry fails', async () => {
+ fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
+ fetchMock.mockRejectedValueOnce(new Error('report-only network failed'))
+
+ await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
+ ok: false,
+ status: null,
+ error: 'report-only network failed',
+ diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
it('falls back when the primary feedback request stalls', async () => {
vi.useFakeTimers()
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
@@ -184,7 +321,7 @@ describe('submitFeedback', () => {
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({
ok: false,
status: null,
- error: 'fallback aborted'
+ error: 'request timed out after 10 seconds'
})
expect(fetchMock).toHaveBeenCalledTimes(2)
})
diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts
index 16c159f6a4c..d9fa48e3c47 100644
--- a/src/main/ipc/feedback.ts
+++ b/src/main/ipc/feedback.ts
@@ -9,7 +9,11 @@ import { app, ipcMain, net } from 'electron'
const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback'
const FEEDBACK_API_FALLBACK_URL = 'https://api.onorca.dev/v1/feedback'
const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000
+const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000
const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson'
+// Why: corporate filters can reject multipart with 403 while allowing the
+// small JSON report, so content-shaped failures should shed the attachment.
+const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422])
export type FeedbackSubmissionType = 'feedback' | 'crash'
@@ -39,13 +43,21 @@ type FeedbackSubmitBody = {
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
}
+export type FeedbackRequestFailure = {
+ status: number | null
+ error: string
+}
+
export type FeedbackSubmitResult =
- | { ok: true }
- | { ok: false; status: number | null; error: string }
+ | { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
+ | ({ ok: false } & FeedbackRequestFailure & {
+ diagnosticBundleFailure?: FeedbackRequestFailure
+ })
type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & {
submissionType?: FeedbackSubmissionType
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
+ feedbackWithoutDiagnosticBundle?: string
}
// Why: the Slack notification and any follow-up investigation need to know
@@ -74,11 +86,15 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
}
}
-async function postFeedback(url: string, body: FeedbackSubmitBody): Promise {
+async function postFeedback(
+ url: string,
+ body: FeedbackSubmitBody,
+ timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
+): Promise {
const controller = new AbortController()
// Why: a silent feedback endpoint should not leave IPC or crash-report
// submission flows pending forever.
- const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS)
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const init: RequestInit = {
method: 'POST',
@@ -86,6 +102,13 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise= 500) {
+ return FEEDBACK_API_FALLBACK_URL
+ }
+ return null
+}
+
+async function submitFeedbackWithoutDiagnosticBundle(
+ url: string,
+ body: FeedbackSubmitBody,
+ diagnosticBundleFailure: FeedbackRequestFailure
+): Promise {
+ try {
+ const response = await postFeedback(url, body)
+ if (response.ok) {
+ return { ok: true, diagnosticBundleFailure }
+ }
+ return { ok: false, ...responseFailure(response), diagnosticBundleFailure }
+ } catch (error) {
+ return { ok: false, ...errorFailure(error), diagnosticBundleFailure }
+ }
+}
+
+async function submitFeedbackWithDiagnosticBundle(
+ body: FeedbackSubmitBody,
+ bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null
+): Promise {
+ try {
+ // Why: diagnostic bundles can approach 4 MiB and need more upload time than
+ // the small JSON report-only path, especially on constrained connections.
+ const response = await postFeedback(
+ FEEDBACK_API_URL,
+ body,
+ FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS
+ )
+ if (response.ok) {
+ return { ok: true }
+ }
+ const failure = responseFailure(response)
+ if (bodyWithoutDiagnosticBundle) {
+ const retryUrl = diagnosticRetryUrl(response.status)
+ if (retryUrl) {
+ return submitFeedbackWithoutDiagnosticBundle(retryUrl, bodyWithoutDiagnosticBundle, failure)
+ }
+ }
+ return { ok: false, ...failure }
+ } catch (error) {
+ const failure = errorFailure(error)
+ return bodyWithoutDiagnosticBundle
+ ? submitFeedbackWithoutDiagnosticBundle(
+ FEEDBACK_API_FALLBACK_URL,
+ bodyWithoutDiagnosticBundle,
+ failure
+ )
+ : { ok: false, ...failure }
+ }
+}
+
export async function submitFeedback(
args: InternalFeedbackSubmitArgs
): Promise {
const body = buildSubmitBody(args)
+ if (body.diagnosticBundle) {
+ const bodyWithoutDiagnosticBundle =
+ args.feedbackWithoutDiagnosticBundle !== undefined
+ ? buildSubmitBody({
+ ...args,
+ feedback: args.feedbackWithoutDiagnosticBundle,
+ diagnosticBundle: undefined
+ })
+ : null
+ return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle)
+ }
try {
const res = await postFeedback(FEEDBACK_API_URL, body)
if (res.ok) {
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 3f50a5ce056..e0fd8ee8fbc 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -27,7 +27,11 @@ import type {
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
-import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
+import {
+ getBranchConflictKind,
+ resolveDefaultBaseRefViaExec,
+ resolveDefaultBaseRefWithLocalGit
+} from '../git/repo'
import { resolveLocalGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
@@ -1999,8 +2003,7 @@ export async function createLocalWorktree(
const baseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: args.baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
- resolveDefaultBaseRef: () =>
- resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
+ resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions),
isBaseUsable: async (baseBranchCandidate) => {
if (runtime) {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts
index 7c6d0572da1..73d10495b1f 100644
--- a/src/main/ipc/worktrees-windows.test.ts
+++ b/src/main/ipc/worktrees-windows.test.ts
@@ -10,6 +10,7 @@ const {
removeWorktreeMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
+ resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getBranchConflictKindMock,
getPRForBranchMock,
@@ -38,6 +39,7 @@ const {
removeWorktreeMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
+ resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
getPRForBranchMock: vi.fn(),
@@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
getDefaultBaseRef: getDefaultBaseRefMock,
+ resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getBranchConflictKind: getBranchConflictKindMock
}))
@@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
removeWorktreeMock.mockReset()
resolveLocalGitUsernameMock.mockReset()
getDefaultBaseRefMock.mockReset()
+ resolveDefaultBaseRefWithLocalGitMock.mockReset()
resolveDefaultBaseRefViaExecMock.mockReset()
getBranchConflictKindMock.mockReset()
getPRForBranchMock.mockReset()
@@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
store.setWorktreeMeta.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
+ resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts
index ade839b50fb..db5bf3b1a4a 100644
--- a/src/main/ipc/worktrees.test.ts
+++ b/src/main/ipc/worktrees.test.ts
@@ -28,7 +28,8 @@ const {
removeWorktreeMock,
forceDeleteLocalBranchMock,
resolveLocalGitUsernameMock,
- getDefaultBaseRefMock,
+ getBaseRefDefaultMock,
+ resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
getBranchConflictKindMock,
@@ -77,7 +78,8 @@ const {
removeWorktreeMock: vi.fn(),
forceDeleteLocalBranchMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
- getDefaultBaseRefMock: vi.fn(),
+ getBaseRefDefaultMock: vi.fn(),
+ resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getDefaultRemoteMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
@@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../git/repo', () => ({
- getDefaultBaseRef: getDefaultBaseRefMock,
+ getBaseRefDefault: getBaseRefDefaultMock,
+ resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getDefaultRemote: getDefaultRemoteMock,
getBranchConflictKind: getBranchConflictKindMock
@@ -300,7 +303,8 @@ describe('registerWorktreeHandlers', () => {
removeWorktreeMock,
forceDeleteLocalBranchMock,
resolveLocalGitUsernameMock,
- getDefaultBaseRefMock,
+ getBaseRefDefaultMock,
+ resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
getBranchConflictKindMock,
@@ -401,7 +405,8 @@ describe('registerWorktreeHandlers', () => {
])
store.getAllWorktreeLineage.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
- getDefaultBaseRefMock.mockReturnValue('origin/main')
+ getBaseRefDefaultMock.mockResolvedValue('origin/main')
+ resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getDefaultRemoteMock.mockResolvedValue('origin')
getBranchConflictKindMock.mockResolvedValue(null)
@@ -525,6 +530,7 @@ describe('registerWorktreeHandlers', () => {
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
+ expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo')
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/master'
@@ -2050,12 +2056,6 @@ describe('registerWorktreeHandlers', () => {
it('routes local worktree creation through the selected WSL project runtime', async () => {
mockSelectedWslProjectRuntime()
- resolveDefaultBaseRefViaExecMock.mockImplementation(
- async (exec: (args: string[]) => Promise<{ stdout: string }>) => {
- await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
- return 'origin/main'
- }
- )
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/repo',
@@ -2087,10 +2087,10 @@ describe('registerWorktreeHandlers', () => {
false,
{ wslDistro: 'Ubuntu' }
)
- expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
- ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
- { cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
- )
+ expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({
+ cwd: '/workspace/repo',
+ wslDistro: 'Ubuntu'
+ })
expect(getBranchConflictKindMock).toHaveBeenCalledWith(
'/workspace/repo',
'improve-dashboard',
@@ -4980,7 +4980,7 @@ describe('registerWorktreeHandlers', () => {
// no origin/main, no origin/master, and no local main/master), we must
// fail loudly with a message that prompts the user to pick a base
// branch, not hand a non-existent ref to `git worktree add`.
- resolveDefaultBaseRefViaExecMock.mockResolvedValue(null)
+ resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null)
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
diff --git a/src/main/observability/tracer.test.ts b/src/main/observability/tracer.test.ts
index d1bec74ab98..d2859436138 100644
--- a/src/main/observability/tracer.test.ts
+++ b/src/main/observability/tracer.test.ts
@@ -5,6 +5,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
_resetTracerForTests,
+ flushActiveSink,
getActiveSpanContext,
setActiveSink,
startSpan,
@@ -69,6 +70,23 @@ describe('tracer — basic span lifecycle', () => {
expect(r.exit.cause).toBe('user-cancelled')
})
+ it('keeps forced sink flush failures from escaping crash boundaries', () => {
+ sink.flush = () => {
+ throw new Error('disk unavailable')
+ }
+
+ expect(() => flushActiveSink()).not.toThrow()
+ })
+
+ it('keeps sink handoff failures from escaping instrumented operations', async () => {
+ sink.push = () => {
+ throw new Error('trace rotation failed')
+ }
+
+ expect(() => startSpan('test').fail('boom')).not.toThrow()
+ await expect(withSpan('async-test', async () => 'result')).resolves.toBe('result')
+ })
+
it('end() is idempotent — second call is a no-op', () => {
const span = startSpan('test')
span.end()
diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts
index 3ef93449cf0..c151aa6a2fb 100644
--- a/src/main/observability/tracer.ts
+++ b/src/main/observability/tracer.ts
@@ -112,6 +112,17 @@ export function setActiveSink(sink: TracerSink | null): void {
activeSink = sink
}
+/** Force records already handed to the tracer onto disk. Reserve this for
+ * crash boundaries where the process may not survive the normal batch window. */
+export function flushActiveSink(): void {
+ try {
+ activeSink?.flush()
+ } catch {
+ // Why: a disk/rotation failure in optional local diagnostics must never
+ // turn a recoverable renderer crash into a main-process crash.
+ }
+}
+
/** Get the current parent context, or `undefined` if we are at the top of
* the trace tree. Renderer-IPC entry points capture this, embed it in
* span-event attributes (so cross-process spans can be visually linked
@@ -221,7 +232,12 @@ export function startSpan(
// Wrap in a `type: 'effect-span'` envelope so the NDJSON file is
// compatible with Effect-style span output. Effect-oriented consumers
// (the LGTM stack, jq cookbooks) can read the file unchanged.
- activeSink?.push({ type: 'effect-span', ...redacted })
+ try {
+ activeSink?.push({ type: 'effect-span', ...redacted })
+ } catch {
+ // Why: tracing is optional; a local file rotation failure must not turn
+ // the instrumented operation into an application failure.
+ }
}
return {
diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts
index 57748417cac..66f68d6e868 100644
--- a/src/main/providers/local-pty-provider.test.ts
+++ b/src/main/providers/local-pty-provider.test.ts
@@ -675,7 +675,8 @@ describe('LocalPtyProvider', () => {
await provider.spawn({
cols: 80,
rows: 24,
- cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
+ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
+ env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' }
})
} finally {
if (savedCodexHome === undefined) {
@@ -693,8 +694,12 @@ describe('LocalPtyProvider', () => {
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
- expect(spawnCall[2].env.WSLENV).toBe(
- 'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
+ expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
+ expect.arrayContaining([
+ 'ORCA_TERMINAL_HANDLE/u',
+ 'ORCA_HERMES_STARTUP_QUERY',
+ POWERLEVEL10K_WIZARD_DISABLE_ENV
+ ])
)
})
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index 806dfdd5d24..90ccae11ea6 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -57,6 +57,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
+import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -567,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider {
// through Windows wsl.exe; non-default env vars need WSLENV import.
addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR'])
}
+ if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
+ // Why: the startup wrapper expands this only inside WSL; wsl.exe
+ // otherwise drops custom Windows environment variables.
+ addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV])
+ }
} else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts
index eddb3f299cf..6213765bb20 100644
--- a/src/main/runtime/mobile-presence-lock.test.ts
+++ b/src/main/runtime/mobile-presence-lock.test.ts
@@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
+ it('elects one query responder and promotes the survivor on unsubscribe', async () => {
+ const { runtime } = createRuntime()
+ await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+ await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
+
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
+
+ runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
+
+ await runtime.reclaimTerminalForDesktop('pty-1')
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
+ })
+
+ it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => {
+ const { runtime } = createRuntime()
+ await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+ runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
+
+ await vi.advanceTimersByTimeAsync(10)
+ await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
+ await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
+ })
+
+ it('excludes an older passive desktop-mode subscriber from reply authority', async () => {
+ const { runtime } = createRuntime()
+ runtime.setMobileDisplayMode('pty-1', 'desktop')
+ await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+ await vi.advanceTimersByTimeAsync(10)
+ await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
+
+ await vi.advanceTimersByTimeAsync(10)
+ runtime.markMobileActor('pty-1', 'phone-B')
+ runtime.setMobileDisplayMode('pty-1', 'auto')
+ await runtime.applyMobileDisplayMode('pty-1')
+
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false)
+ expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
+ })
+
it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 1f0fb590010..b8d9eee175f 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -45,7 +45,7 @@ import {
runHook,
shouldRunSetupForCreate
} from '../hooks'
-import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo'
+import { getBaseRefDefault, getBranchConflictKind } from '../git/repo'
import type { OrchestrationDb } from './orchestration/db'
import type { MessagePriority, MessageRow, MessageType } from './orchestration/types'
import {
@@ -521,16 +521,23 @@ vi.mock('../github/issues', async (importOriginal) => {
}
})
-// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
-// fallback base branch. Real resolution shells out to `git` against the
-// test's fabricated repo path, which has no refs, so we stub it to a
-// predictable 'origin/main'. The runtime no longer silently fabricates this
-// default, so tests that want the legacy behavior must express it via the mock.
+// Why: CLI worktree creation resolves a default against fabricated repo paths
+// in these tests, so keep the async resolver deterministic.
vi.mock('../git/repo', async (importOriginal) => {
const actual = (await importOriginal()) as Record
+ const actualGetBaseRefDefault = actual.getBaseRefDefault as (
+ path: string,
+ options?: { wslDistro?: string }
+ ) => Promise
return {
...actual,
- getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
+ // Why: fabricated local test repos need a deterministic default, while
+ // WSL coverage must still exercise the real async Git-options path.
+ getBaseRefDefault: vi
+ .fn()
+ .mockImplementation((path: string, options?: { wslDistro?: string }) =>
+ options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main')
+ ),
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
@@ -2857,7 +2864,7 @@ describe('OrcaRuntimeService', () => {
suggestLocalBaseRefUpdate: true
}
)
- expect(getDefaultBaseRef).toHaveBeenCalled()
+ expect(getBaseRefDefault).toHaveBeenCalled()
} finally {
getReposSpy.mockRestore()
gitSpy.mockRestore()
@@ -5363,7 +5370,7 @@ describe('OrcaRuntimeService', () => {
it('treats SSH worktree drift as unknown without local git probes', async () => {
vi.mocked(listWorktrees).mockClear()
- vi.mocked(getDefaultBaseRef).mockClear()
+ vi.mocked(getBaseRefDefault).mockClear()
const remoteStore = {
...store,
getRepos: () => [
@@ -5399,7 +5406,7 @@ describe('OrcaRuntimeService', () => {
}
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
- expect(getDefaultBaseRef).not.toHaveBeenCalled()
+ expect(getBaseRefDefault).not.toHaveBeenCalled()
expect(listWorktrees).not.toHaveBeenCalled()
})
@@ -5465,7 +5472,7 @@ describe('OrcaRuntimeService', () => {
})
expect(asyncGitSpy).toHaveBeenCalledWith(
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
- wslGitOptions
+ { ...wslGitOptions, timeout: 15_000 }
)
expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions)
expect(asyncGitSpy).toHaveBeenCalledWith(
@@ -26473,6 +26480,7 @@ describe('OrcaRuntimeService', () => {
})
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: TEST_REPO_PATH,
+ timeout: 15_000,
wslDistro: 'Ubuntu'
})
expect(gitSpy).toHaveBeenCalledWith(
@@ -27140,6 +27148,7 @@ describe('OrcaRuntimeService', () => {
})
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: TEST_REPO_PATH,
+ timeout: 15_000,
wslDistro: 'Ubuntu'
})
expect(getBranchConflictKind).toHaveBeenCalledWith(
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 0f60418e721..84f1954d194 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -600,7 +600,6 @@ import type {
} from '../../shared/github-project-types'
import {
getBaseRefDefault,
- getDefaultBaseRef,
getDefaultRemote,
getBranchConflictKind,
isGitRepo,
@@ -611,6 +610,7 @@ import {
parseAndFilterSearchRefDetails,
parseRemoteCount,
resolveDefaultBaseRefViaExec,
+ resolveDefaultBaseRefWithLocalGit,
buildSearchBaseRefsArgv,
isForEachRefExcludeUnsupportedError,
mergeBaseRefSearchResultGroups,
@@ -6497,6 +6497,31 @@ export class OrcaRuntimeService {
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
}
+ isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
+ // Why: a passive phone watching desktop-sized output must not race the
+ // desktop xterm. Mobile becomes reply authority only with the mobile floor.
+ if (this.getDriver(ptyId).kind !== 'mobile') {
+ return false
+ }
+ const subscribers = this.mobileSubscribers.get(ptyId)
+ if (!subscribers) {
+ return false
+ }
+ // Why: soft-leave resubscribe preserves the original subscription time but
+ // reinserts the record. Elect fitted responders from that stable age, not
+ // mutable Map order or passive desktop-mode watchers.
+ let earliest: { clientId: string; subscribedAt: number } | null = null
+ for (const subscriber of subscribers.values()) {
+ if (!subscriber.wasResizedToPhone) {
+ continue
+ }
+ if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) {
+ earliest = subscriber
+ }
+ }
+ return earliest?.clientId === clientId
+ }
+
subscribeToFitOverrideChanges(
ptyId: string,
listener: (event: {
@@ -14670,8 +14695,8 @@ export class OrcaRuntimeService {
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () =>
hasLocalWorktreeGitOptions
- ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
- : Promise.resolve(getDefaultBaseRef(repo.path)),
+ ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions)
+ : getBaseRefDefault(repo.path),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
repo.path,
@@ -14702,10 +14727,8 @@ export class OrcaRuntimeService {
}
})
if (!baseBranch) {
- // Why: getDefaultBaseRef returns null when no suitable ref exists.
- // Don't fabricate 'origin/main' — passing it to addWorktree would
- // produce an opaque git failure. Surface a clear error so the CLI
- // caller can pick an explicit --base ref.
+ // Why: a null default means no suitable ref exists; fail clearly instead
+ // of handing Git a fabricated origin/main ref.
throw new Error(
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
)
diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts
index b94e88e4d6f..9c39a65f456 100644
--- a/src/main/runtime/rpc/methods/terminal.ts
+++ b/src/main/runtime/rpc/methods/terminal.ts
@@ -26,6 +26,13 @@ import {
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
+import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
+import {
+ EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
+ scanTerminalReplyQuerySequences,
+ type TerminalReplyQuerySequence,
+ type TerminalReplyQueryScanState
+} from '../../../../shared/terminal-reply-query-scan'
import {
MOBILE_SNAPSHOT_BYTE_BUDGET,
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
@@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
+const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
let nextTerminalStreamId = 1
type SnapshotFrameOptions = {
@@ -380,6 +388,29 @@ function getOutputAfterSnapshotSeq(
return chunk.data.slice(snapshotSeq - chunkStartSeq)
}
+function stripSnapshotBoundaryQuerySuffixes(
+ data: string,
+ dataStartSeq: number,
+ snapshotSeq: number,
+ queries: TerminalReplyQuerySequence[]
+): string {
+ let output = ''
+ let offset = 0
+ for (const query of queries) {
+ if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) {
+ continue
+ }
+ const removeStart = Math.max(0, query.startSeq - dataStartSeq)
+ const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq)
+ if (removeEnd <= offset || removeStart >= data.length) {
+ continue
+ }
+ output += data.slice(offset, removeStart)
+ offset = removeEnd
+ }
+ return output + data.slice(offset)
+}
+
function appendAckPendingOutput(
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
@@ -731,6 +762,9 @@ const TerminalSend = TerminalHandle.extend({
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
+ // Why: terminal-generated replies are valid input bytes but are not a user
+ // action that should transfer the shared terminal floor.
+ inputKind: z.enum(['query-reply']).optional(),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients (server falls back to
// the most recent mobile actor when absent). New mobile builds populate
@@ -1025,13 +1059,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.send',
params: TerminalSend,
- handler: async (params, { runtime }) => {
+ handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
+ const queryReplyClientId = clientId ?? params.client?.id
+ if (
+ params.inputKind === 'query-reply' &&
+ (!params.text ||
+ !isTerminalQueryReply(params.text) ||
+ params.enter === true ||
+ params.interrupt === true ||
+ params.requireAgentStatus !== undefined ||
+ params.client?.type !== 'mobile' ||
+ !queryReplyClientId ||
+ (clientId !== undefined && params.client.id !== clientId))
+ ) {
+ throw new InvalidArgumentError('Invalid terminal query reply')
+ }
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale (clients recover by re-deriving the handle)
// instead of evaluating driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
+ if (
+ params.inputKind === 'query-reply' &&
+ leaf?.ptyId &&
+ !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!)
+ ) {
+ return {
+ send: {
+ handle: params.terminal,
+ accepted: false,
+ bytesWritten: 0
+ }
+ }
+ }
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
@@ -1166,7 +1227,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// the most recent actor. Clientless sends are old mobile builds, so use
// the current mobile driver as their compatibility identity.
const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client)
- if (leaf?.ptyId && mobileFloorClientId) {
+ if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) {
await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId)
}
return { send: result }
@@ -2426,6 +2487,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let desktopClaimTail: Promise = Promise.resolve(true)
let pendingOutputBytes = 0
let pendingOutputOverflowed = false
+ let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
+ const pendingQuerySequences: TerminalReplyQuerySequence[] = []
+ let pendingQueryChars = 0
+ let pendingQueryOverflowed = false
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
@@ -2600,6 +2665,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
.catch(() => false)
}
}) ?? (() => {})
+ const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
+ if (closed) {
+ return
+ }
+ if (buffering) {
+ const rawLength = meta?.rawLength
+ if (
+ typeof meta?.seq === 'number' &&
+ typeof rawLength === 'number' &&
+ rawLength === data.length
+ ) {
+ const scan = scanTerminalReplyQuerySequences(
+ data,
+ meta.seq - rawLength,
+ pendingQueryScanState
+ )
+ pendingQueryScanState = scan.state
+ for (const query of scan.queries) {
+ if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) {
+ pendingQueryOverflowed = true
+ break
+ }
+ pendingQuerySequences.push(query)
+ pendingQueryChars += query.data.length
+ }
+ } else {
+ pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
+ }
+ const remainingBudget = Math.max(
+ 1,
+ TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
+ )
+ const measurement = measureTerminalStreamByteLength(data, {
+ stopAfterBytes: remainingBudget
+ })
+ pendingOutput.push({ data, bytes: measurement.byteLength, meta })
+ pendingOutputBytes += measurement.byteLength
+ const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
+ pendingOutputBytes = trimmed.bytes
+ pendingOutputOverflowed ||= trimmed.overflowed
+ return
+ }
+ outputBatcher?.push(data, meta)
+ })
+ // Why: live bytes must be captured before mobile fit awaits. Registering
+ // mobile presence first would suppress main while no view held the query.
+ const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
+ unsubscribeData = () => {
+ releaseViewSubscriber()
+ unsubscribeStreamData()
+ }
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
try {
if (isMobile && clientId) {
@@ -2614,59 +2730,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
- const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
- if (closed) {
- return
- }
- if (buffering) {
- const remainingBudget = Math.max(
- 1,
- TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
- )
- const measurement = measureTerminalStreamByteLength(data, {
- stopAfterBytes: remainingBudget
- })
- pendingOutput.push({ data, bytes: measurement.byteLength, meta })
- pendingOutputBytes += measurement.byteLength
- const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
- pendingOutputBytes = trimmed.bytes
- pendingOutputOverflowed ||= trimmed.overflowed
- return
- }
- outputBatcher?.push(data, meta)
- })
- // Why: binary subscribe streams feed remote xterm views (mobile and
- // binary-capable desktop clients) that answer queries with view
- // authority; the main model responder yields while attached.
- const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
- unsubscribeData = () => {
- releaseViewSubscriber()
- unsubscribeStreamData()
- }
-
- if (
- clientId &&
- params.client &&
- registeredRemoteDesktopDriver &&
- pendingRemoteDesktopViewport
- ) {
- const viewport = pendingRemoteDesktopViewport
- pendingRemoteDesktopViewport = null
- await updateViewportForClient(
- runtime,
- ptyId,
- remoteDesktopSubscriptionKey,
- params.client,
- viewport,
- 'desktop',
- 'register',
- !supportsDesktopViewportClaims
- )
- }
- if (closed) {
- return
- }
-
let read = await runtime.readTerminal(params.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed) {
@@ -2697,7 +2760,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// See docs/mobile-terminal-layout-state-machine.md.
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
- const snapshotOutputSeq = serialized?.seq
+ // Why: recovery snapshots advance output coverage past the initial
+ // snapshot seq; query replay and boundary trims must track the seq
+ // that actually covered the buffered chunks or a query absorbed by a
+ // recovery snapshot gets zero replies.
+ let snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
streamId,
@@ -2784,12 +2851,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
pendingOutput = trimmed.chunks
pendingOutputBytes = trimmed.bytes
+ snapshotOutputSeq = recovery.seq
}
buffering = false
const bufferedOutput = pendingOutput.splice(0)
+ const queryReplayData = pendingQueryOverflowed
+ ? ''
+ : pendingQuerySequences
+ .filter(
+ (query) =>
+ initialOutputOverflowed ||
+ (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq)
+ )
+ .map((query) => query.data)
+ .join('')
+ if (queryReplayData) {
+ // Why: serialized snapshots omit control queries, yet their output seq
+ // can trim the live chunk. Replay only the query after snapshot so the
+ // mobile xterm answers once while ordinary output stays deduplicated.
+ outputBatcher.push(queryReplayData)
+ }
if (!initialOutputOverflowed) {
for (const item of bufferedOutput) {
- const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
+ let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
+ if (
+ uncoveredData &&
+ uncoveredData !== item.data &&
+ typeof snapshotOutputSeq === 'number' &&
+ typeof item.meta?.seq === 'number' &&
+ typeof item.meta.rawLength === 'number'
+ ) {
+ uncoveredData = stripSnapshotBoundaryQuerySuffixes(
+ uncoveredData,
+ snapshotOutputSeq,
+ snapshotOutputSeq,
+ pendingQuerySequences
+ )
+ }
if (uncoveredData) {
outputBatcher.push(uncoveredData, item.meta)
}
diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts
index 7443b95c7cd..12e40c2c977 100644
--- a/src/main/runtime/rpc/terminal-send.test.ts
+++ b/src/main/runtime/rpc/terminal-send.test.ts
@@ -4,6 +4,10 @@ import type { RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import { TERMINAL_METHODS } from './methods/terminal'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text'
+import {
+ RUNTIME_CAPABILITIES,
+ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
+} from '../../../shared/protocol-version'
import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR
@@ -209,6 +213,148 @@ describe('terminal send RPC', () => {
expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
})
+ it('writes a validated terminal query reply without taking the mobile floor', async () => {
+ const runtime = stubRuntime({
+ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+ getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
+ sendTerminal: vi.fn().mockResolvedValue({
+ handle: 'terminal-1',
+ accepted: true,
+ bytesWritten: 6
+ }),
+ isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true),
+ mobileTookFloor: vi.fn()
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('terminal.send', {
+ terminal: 'terminal-1',
+ text: '\x1b[3;4R',
+ enter: false,
+ inputKind: 'query-reply',
+ client: { id: 'mobile-1', type: 'mobile' }
+ })
+ )
+
+ expect(response.ok).toBe(true)
+ expect(runtime.sendTerminal).toHaveBeenCalledWith(
+ 'terminal-1',
+ { text: '\x1b[3;4R', enter: false, interrupt: false },
+ { beforeWrite: undefined }
+ )
+ expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
+ })
+
+ it('accepts a query reply from only the elected mobile subscriber', async () => {
+ const runtime = stubRuntime({
+ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+ getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
+ isMobileTerminalQueryReplyAuthority: vi.fn(
+ (_ptyId: string, clientId: string) => clientId === 'mobile-1'
+ ),
+ sendTerminal: vi.fn().mockResolvedValue({
+ handle: 'terminal-1',
+ accepted: true,
+ bytesWritten: 6
+ }),
+ mobileTookFloor: vi.fn()
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ const makeReply = (clientId: string) =>
+ dispatcher.dispatch(
+ makeRequest('terminal.send', {
+ terminal: 'terminal-1',
+ text: '\x1b[3;4R',
+ inputKind: 'query-reply',
+ client: { id: clientId, type: 'mobile' }
+ })
+ )
+ const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')])
+
+ expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } })
+ expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } })
+ expect(runtime.sendTerminal).toHaveBeenCalledTimes(1)
+ expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
+ })
+
+ it('rejects ordinary input that claims to be a terminal query reply', async () => {
+ const runtime = stubRuntime({
+ resolveLiveLeafForHandle: vi.fn(),
+ sendTerminal: vi.fn(),
+ mobileTookFloor: vi.fn()
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('terminal.send', {
+ terminal: 'terminal-1',
+ text: 'a',
+ inputKind: 'query-reply',
+ client: { id: 'mobile-1', type: 'mobile' }
+ })
+ )
+
+ expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
+ expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
+ expect(runtime.sendTerminal).not.toHaveBeenCalled()
+ expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['enter', { enter: true }],
+ ['interrupt', { interrupt: true }],
+ ['agent guard', { requireAgentStatus: 'sendable' }]
+ ])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => {
+ const runtime = stubRuntime({
+ resolveLiveLeafForHandle: vi.fn(),
+ sendTerminal: vi.fn()
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('terminal.send', {
+ terminal: 'terminal-1',
+ text: '\x1b[3;4R',
+ inputKind: 'query-reply',
+ client: { id: 'mobile-1', type: 'mobile' },
+ ...extra
+ })
+ )
+
+ expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
+ expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
+ expect(runtime.sendTerminal).not.toHaveBeenCalled()
+ })
+
+ it('rejects query replies that spoof a different authenticated mobile client', async () => {
+ const replies: string[] = []
+ const runtime = stubRuntime({
+ resolveLiveLeafForHandle: vi.fn(),
+ sendTerminal: vi.fn()
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ await dispatcher.dispatchStreaming(
+ makeRequest('terminal.send', {
+ terminal: 'terminal-1',
+ text: '\x1b[3;4R',
+ inputKind: 'query-reply',
+ client: { id: 'spoofed-mobile', type: 'mobile' }
+ }),
+ (reply) => replies.push(reply),
+ { clientId: 'authenticated-mobile' }
+ )
+
+ expect(JSON.parse(replies[0]!)).toMatchObject({
+ ok: false,
+ error: { code: 'invalid_argument' }
+ })
+ expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
+ expect(runtime.sendTerminal).not.toHaveBeenCalled()
+ })
+
it('rejects oversized terminal send text before runtime dispatch', async () => {
const secret = 'terminal-send-secret'
const runtime = stubRuntime({
@@ -475,4 +621,9 @@ describe('terminal send RPC', () => {
expect(response.result).toEqual({ restored: true })
expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1')
})
+ // Why: mobile gates reply forwarding on this static capability; removing it
+ // would silently re-break mobile query answering against current hosts.
+ it('advertises query-reply input support to mobile clients', () => {
+ expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY)
+ })
})
diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
index 1ca811754f3..27972821f65 100644
--- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
+++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
@@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => {
}
})
+ it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => {
+ const binaryFrames: Uint8Array[] = []
+ const cleanups = new Map void>()
+ let dataListener:
+ | ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
+ | undefined
+ let resolveMobileSubscribe: () => void = () => {}
+ const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn())
+ const runtime = stubRuntime({
+ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+ handleMobileSubscribe: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveMobileSubscribe = () => resolve(true)
+ })
+ ),
+ handleMobileUnsubscribe: vi.fn(),
+ subscribeToTerminalData: vi.fn((_ptyId, listener) => {
+ dataListener = listener
+ return vi.fn()
+ }),
+ registerRemoteTerminalViewSubscriber,
+ readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
+ serializeTerminalBuffer: vi
+ .fn()
+ .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }),
+ getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
+ getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
+ getLayout: vi.fn().mockReturnValue({ seq: 1 }),
+ isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
+ subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
+ subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
+ registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
+ cleanups.set(id, cleanup)
+ }),
+ cleanupSubscription: vi.fn((id: string) => {
+ cleanups.get(id)?.()
+ cleanups.delete(id)
+ }),
+ waitForTerminal: vi.fn(() => new Promise(() => {}))
+ })
+ const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
+
+ const dispatchPromise = dispatcher.dispatchStreaming(
+ makeRequest('terminal.subscribe', {
+ terminal: 'terminal-1',
+ client: { id: 'phone-1', type: 'mobile' },
+ capabilities: { terminalBinaryStream: 1 }
+ }),
+ vi.fn(),
+ {
+ connectionId: 'conn-phone',
+ sendBinary: (bytes) => {
+ binaryFrames.push(bytes)
+ },
+ registerBinaryStreamHandler: vi.fn(() => vi.fn())
+ }
+ )
+
+ await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
+ expect(dataListener).toBeDefined()
+ expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1')
+ dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 })
+ resolveMobileSubscribe()
+
+ await vi.waitFor(() =>
+ expect(
+ binaryFrames.some((bytes) => {
+ const frame = decodeTerminalStreamFrame(bytes)
+ return (
+ frame?.opcode === TerminalStreamOpcode.Output &&
+ decodeTerminalStreamText(frame.payload) === '\x1b[6n'
+ )
+ })
+ ).toBe(true)
+ )
+ const queryOutputFrames = binaryFrames.filter((bytes) => {
+ const frame = decodeTerminalStreamFrame(bytes)
+ return (
+ frame?.opcode === TerminalStreamOpcode.Output &&
+ decodeTerminalStreamText(frame.payload) === '\x1b[6n'
+ )
+ })
+ expect(queryOutputFrames).toHaveLength(1)
+
+ runtime.cleanupSubscription('terminal-1:phone-1')
+ await dispatchPromise
+ })
+
it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => {
const messages: string[] = []
const runtime = stubRuntime({
diff --git a/src/main/win32-utils-async-acl.test.ts b/src/main/win32-utils-async-acl.test.ts
new file mode 100644
index 00000000000..22c9ff992c8
--- /dev/null
+++ b/src/main/win32-utils-async-acl.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
+ execFileMock: vi.fn(),
+ execFileSyncMock: vi.fn()
+}))
+
+vi.mock('node:child_process', () => ({
+ execFile: execFileMock,
+ execFileSync: execFileSyncMock
+}))
+
+type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void
+
+const originalUsername = process.env.USERNAME
+
+beforeEach(() => {
+ vi.resetModules()
+ execFileMock.mockReset()
+ execFileSyncMock.mockReset()
+})
+
+afterEach(() => {
+ if (originalUsername === undefined) {
+ delete process.env.USERNAME
+ } else {
+ process.env.USERNAME = originalUsername
+ }
+})
+
+describe('grantDirAclAsync', () => {
+ it('keeps icacls off the synchronous main-process path', async () => {
+ process.env.USERNAME = 'alice'
+ let complete: ExecCallback | undefined
+ execFileMock.mockImplementation(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ complete = callback
+ return {} as never
+ }
+ )
+ const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils')
+
+ const pending = grantDirAclAsync('C:\\Users\\alice\\Orca')
+ await Promise.resolve()
+
+ expect(execFileSyncMock).not.toHaveBeenCalled()
+ expect(execFileMock).toHaveBeenCalledWith(
+ getIcaclsExePath(),
+ ['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'],
+ { encoding: 'utf-8', windowsHide: true, timeout: 10_000 },
+ expect.any(Function)
+ )
+ complete?.(null, '', '')
+ await expect(pending).resolves.toBeUndefined()
+ })
+
+ it('resolves a missing username through asynchronous whoami before icacls', async () => {
+ delete process.env.USERNAME
+ execFileMock
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
+ return {} as never
+ }
+ )
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(null, '', '')
+ return {} as never
+ }
+ )
+ const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
+
+ await grantDirAclAsync('C:\\Orca')
+
+ expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([
+ getWhoamiExePath(),
+ ['/user', '/fo', 'csv', '/nh'],
+ { encoding: 'utf-8', windowsHide: true, timeout: 5000 }
+ ])
+ expect(execFileMock.mock.calls[1]?.[1]).toEqual([
+ 'C:\\Orca',
+ '/grant:r',
+ '*S-1-5-21-123:(OI)(CI)(F)'
+ ])
+ expect(execFileSyncMock).not.toHaveBeenCalled()
+ })
+
+ it('does not overwrite a concurrent cached identity with a late async result', async () => {
+ delete process.env.USERNAME
+ let completeWhoami: ExecCallback | undefined
+ execFileMock
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ completeWhoami = callback
+ return {} as never
+ }
+ )
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(null, '', '')
+ return {} as never
+ }
+ )
+ execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n')
+ const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils')
+
+ const pending = grantDirAclAsync('C:\\Orca')
+ await Promise.resolve()
+ expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456')
+ completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '')
+ await pending
+
+ expect(execFileMock.mock.calls[1]?.[1]).toEqual([
+ 'C:\\Orca',
+ '/grant:r',
+ '*S-1-5-21-456:(OI)(CI)(F)'
+ ])
+ })
+
+ it('retries identity resolution after a transient whoami failure', async () => {
+ delete process.env.USERNAME
+ execFileMock
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(new Error('whoami timed out'), '', '')
+ return {} as never
+ }
+ )
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
+ return {} as never
+ }
+ )
+ .mockImplementationOnce(
+ (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
+ callback(null, '', '')
+ return {} as never
+ }
+ )
+ const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
+
+ await grantDirAclAsync('C:\\Orca')
+ await grantDirAclAsync('C:\\Orca')
+
+ expect(
+ execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath())
+ ).toHaveLength(2)
+ expect(execFileMock.mock.calls[2]?.[1]).toEqual([
+ 'C:\\Orca',
+ '/grant:r',
+ '*S-1-5-21-123:(OI)(CI)(F)'
+ ])
+ expect(execFileSyncMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts
index a4b8dae1286..264ed6e6604 100644
--- a/src/main/win32-utils.ts
+++ b/src/main/win32-utils.ts
@@ -1,7 +1,23 @@
-import { execFileSync } from 'node:child_process'
+import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process'
import { delimiter, join } from 'node:path'
import { existsSync } from 'node:fs'
+function execFileWithoutBlocking(
+ command: string,
+ args: string[],
+ options: ExecFileOptionsWithStringEncoding
+): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(command, args, options, (error, stdout) => {
+ if (error) {
+ reject(error)
+ return
+ }
+ resolve(stdout)
+ })
+ })
+}
+
/**
* Full path to icacls.exe. Electron's main process may have a stripped PATH
* that excludes System32, causing bare `icacls` to throw ENOENT.
@@ -10,6 +26,11 @@ export function getIcaclsExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`
}
+/** Absolute path because service-launched Electron can omit System32 from PATH. */
+export function getWhoamiExePath(): string {
+ return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe`
+}
+
/**
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
@@ -78,13 +99,10 @@ export function isPermissionError(error: unknown): boolean {
// and hardened envs where USERNAME is unset. Fall back to the SID via
// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative
// and always available on Windows. Cached because it never changes in-process.
-let cachedIdentity: string | null | undefined
+let cachedIdentity: string | undefined
+let pendingIdentityResolution: Promise | null = null
-export function resolveCurrentWindowsIdentity(): string | null {
- return resolveCurrentIdentity()
-}
-
-function resolveCurrentIdentity(): string | null {
+function cachedOrEnvironmentIdentity(): string | undefined {
if (cachedIdentity !== undefined) {
return cachedIdentity
}
@@ -92,20 +110,75 @@ function resolveCurrentIdentity(): string | null {
cachedIdentity = process.env.USERNAME
return cachedIdentity
}
+ return undefined
+}
+
+function identityFromWhoamiOutput(output: string): string | null {
+ // CSV columns: "DOMAIN\\user","S-1-5-21-..."
+ const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim())
+ return sidMatch ? `*${sidMatch[1]}` : null
+}
+
+export function resolveCurrentWindowsIdentity(): string | null {
+ return resolveCurrentIdentity()
+}
+
+function resolveCurrentIdentity(): string | null {
+ const knownIdentity = cachedOrEnvironmentIdentity()
+ if (knownIdentity !== undefined) {
+ return knownIdentity
+ }
try {
- const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
+ const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
- }).trim()
- // CSV columns: "DOMAIN\\user","S-1-5-21-..."
- const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output)
- cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null
+ })
+ const resolvedIdentity = identityFromWhoamiOutput(output)
+ if (resolvedIdentity) {
+ cachedIdentity = resolvedIdentity
+ }
+ return resolvedIdentity
} catch {
- cachedIdentity = null
+ return null
}
- return cachedIdentity
+}
+
+async function resolveCurrentIdentityAsync(): Promise {
+ const knownIdentity = cachedOrEnvironmentIdentity()
+ if (knownIdentity !== undefined) {
+ return knownIdentity
+ }
+ if (!pendingIdentityResolution) {
+ pendingIdentityResolution = (async () => {
+ try {
+ const stdout = await execFileWithoutBlocking(
+ getWhoamiExePath(),
+ ['/user', '/fo', 'csv', '/nh'],
+ {
+ encoding: 'utf-8',
+ windowsHide: true,
+ timeout: 5000
+ }
+ )
+ // Why: a synchronous caller may resolve identity while async whoami
+ // is in flight; its authoritative cached result must win the race.
+ const resolvedIdentity = identityFromWhoamiOutput(stdout)
+ if (cachedIdentity === undefined && resolvedIdentity) {
+ cachedIdentity = resolvedIdentity
+ }
+ return cachedIdentity ?? resolvedIdentity
+ } catch {
+ // Why: transient service/PATH failures must not permanently disable
+ // ACL repair for every later crash attempt in this process.
+ return cachedIdentity ?? null
+ }
+ })().finally(() => {
+ pendingIdentityResolution = null
+ })
+ }
+ return pendingIdentityResolution
}
/**
@@ -138,6 +211,24 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }):
})
}
+export async function grantDirAclAsync(dirPath: string): Promise {
+ const identity = await resolveCurrentIdentityAsync()
+ if (!identity) {
+ return
+ }
+ // Why: crash recovery runs on Electron's main thread; an asynchronous
+ // icacls child keeps its worst-case timeout from freezing every window.
+ await execFileWithoutBlocking(
+ getIcaclsExePath(),
+ [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`],
+ {
+ encoding: 'utf-8',
+ windowsHide: true,
+ timeout: 10_000
+ }
+ )
+}
+
/**
* Resolve spawn parameters for a command that may be a Windows batch script.
*
diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts
index b72be7962ea..54b4728f7cd 100644
--- a/src/main/window/createMainWindow.test.ts
+++ b/src/main/window/createMainWindow.test.ts
@@ -2390,7 +2390,7 @@ describe('createMainWindow', () => {
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142)
})
- it('passes the renderer webContents id through crash classification callbacks', () => {
+ it('passes the renderer webContents id through crash recording and recovery callbacks', () => {
vi.useFakeTimers()
const windowHandlers: Record void> = {}
@@ -2423,13 +2423,11 @@ describe('createMainWindow', () => {
return browserWindowInstance
})
const onRendererProcessGone = vi.fn()
- const shouldRecordRendererCrash = vi.fn(() => true)
const shouldRecoverRenderer = vi.fn(() => true)
try {
createMainWindow(null, {
onRendererProcessGone,
- shouldRecordRendererCrash,
shouldRecoverRenderer
})
@@ -2437,7 +2435,6 @@ describe('createMainWindow', () => {
windowHandlers['render-process-gone']?.({} as never, details)
vi.advanceTimersByTime(250)
- expect(shouldRecordRendererCrash).toHaveBeenCalledWith(details, 424)
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424)
expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424)
} finally {
@@ -2445,9 +2442,10 @@ describe('createMainWindow', () => {
}
})
- it('does not notify the crash recorder for an expected renderer teardown', () => {
+ it('forwards expected renderer teardowns so the recorder can diagnose suppression', () => {
const windowHandlers: Record void> = {}
const webContents = {
+ id: 142,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
@@ -2476,10 +2474,7 @@ describe('createMainWindow', () => {
})
const onRendererProcessGone = vi.fn()
- createMainWindow(null, {
- onRendererProcessGone,
- shouldRecordRendererCrash: () => false
- })
+ createMainWindow(null, { onRendererProcessGone })
windowHandlers['render-process-gone']?.(
{} as never,
@@ -2489,7 +2484,10 @@ describe('createMainWindow', () => {
} as Electron.RenderProcessGoneDetails
)
- expect(onRendererProcessGone).not.toHaveBeenCalled()
+ expect(onRendererProcessGone).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'killed', exitCode: 15 }),
+ expect.any(Number)
+ )
consoleError.mockRestore()
})
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index 1a30178a4e2..ff66b5ebc16 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -111,13 +111,6 @@ type CreateMainWindowOptions = {
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => void
- /** Returns true when a renderer loss should be reported as a crash. Why:
- * intentional reload/update/quit paths can emit crash-like `killed`
- * renderer exits, but surfacing those as crash reports is noise. */
- shouldRecordRendererCrash?: (
- details: Electron.RenderProcessGoneDetails,
- webContentsId: number
- ) => boolean
/** Returns true when Orca should reload after an unexpected renderer loss.
* Why: update relaunch and app quit intentionally tear down child
* processes; recovering those paths can fight Electron's shutdown. */
@@ -713,10 +706,9 @@ export function createMainWindow(
resetShortcutRecorderFocus()
// Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL
// after a confirmed close; that is window lifecycle noise, not a crash.
- if (
- !windowClosing &&
- opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false
- ) {
+ if (!windowClosing) {
+ // Why: the recorder owns crash classification and durable suppression
+ // diagnostics; filtering here made expected-teardown evidence unreachable.
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
}
if (!windowClosing) {
diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts
index 42add57be66..8653c6866eb 100644
--- a/src/main/worktree-create-base-prefetch.ts
+++ b/src/main/worktree-create-base-prefetch.ts
@@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind'
import type { Repo } from '../shared/types'
import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
-import { getDefaultBaseRef } from './git/repo'
+import { getBaseRefDefault } from './git/repo'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
import { resolveWorktreeCreateBase } from './worktree-create-base'
@@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase(
const resolvedBaseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
- resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
+ resolveDefaultBaseRef: () => getBaseRefDefault(repo.path),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
repo.path,
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 9b91002fb78..1bad72bb019 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -316,6 +316,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
CrashReportBreadcrumbData,
+ CrashReportCopyDiagnosticsArgs,
CrashReportRecord,
CrashReportSubmitArgs,
CrashReportSubmitResult,
@@ -1398,10 +1399,9 @@ export type PreloadApi = {
) => Promise
recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void
submit: (args: CrashReportSubmitArgs) => Promise
- copyLatestDiagnostics: (args?: {
- reportId?: string
- notes?: string
- }) => Promise<{ ok: true } | { ok: false; error: string }>
+ copyLatestDiagnostics: (
+ args?: CrashReportCopyDiagnosticsArgs
+ ) => Promise<{ ok: true } | { ok: false; error: string }>
}
export: ExportApi
gh: {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 837a87b9715..6f8692649b4 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -212,6 +212,7 @@ import type {
} from '../shared/localhost-worktree-labels'
import type {
CrashReportBreadcrumbData,
+ CrashReportCopyDiagnosticsArgs,
CrashReportSubmitArgs,
CrashReportSubmitResult,
ReactErrorBoundaryReportArgs,
@@ -1151,7 +1152,7 @@ const api = {
ipcRenderer.send('crashReports:recordBreadcrumb', args),
submit: (args: CrashReportSubmitArgs): Promise =>
ipcRenderer.invoke('crashReports:submit', args),
- copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) =>
+ copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) =>
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args)
},
diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx
index d80deb581e5..0f263bf5310 100644
--- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx
+++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx
@@ -16,10 +16,18 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import {
formatCrashReportText,
isReactErrorBoundaryReport,
+ type CrashReportDiagnosticBundle,
type CrashReportRecord
} from '../../../../shared/crash-reporting'
import type { GitHubViewer } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
+import {
+ CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
+ getCrashReportCopySubmissionFailure,
+ getCrashReportSubmitFailureNotice,
+ getCrashReportSubmitWarningNotice
+} from './crash-report-submit-notice'
+import { useCrashReportCopy } from './use-crash-report-copy'
function formatSummary(report: CrashReportRecord): string {
if (isReactErrorBoundaryReport(report)) {
@@ -88,6 +96,7 @@ export function CrashReportDialogSurface({
() => (report ? formatCrashReportText(report, deferredNotes) : ''),
[deferredNotes, report]
)
+ const copyCrashReportDetails = useCrashReportCopy(report, notes)
const clearViewer = useCallback((): void => {
viewerRequestIdRef.current += 1
@@ -121,17 +130,25 @@ export function CrashReportDialogSurface({
loadViewerForOpenDialog()
}, [clearViewer, loadViewerForOpenDialog, open])
- const handleCopy = async (): Promise => {
- const result = await window.api.crashReports.copyLatestDiagnostics(
- report ? { reportId: report.id, notes } : { notes }
- )
- if (!result.ok) {
- toast.error(result.error)
- return
- }
- toast.success(
- translate('auto.components.crash.report.CrashReportDialog.8b8473c544', 'Crash report copied.')
- )
+ const showSubmitFailure = (
+ error: unknown,
+ diagnosticBundle?: CrashReportDiagnosticBundle
+ ): void => {
+ const failure = { error, ...(diagnosticBundle ? { diagnosticBundle } : {}) }
+ const notice = getCrashReportSubmitFailureNotice(failure, includeDiagnosticLogs)
+ const copyFailure = getCrashReportCopySubmissionFailure(failure)
+ toast.error(notice.title, {
+ id: CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
+ description: notice.description,
+ duration: Infinity,
+ dismissible: true,
+ action: {
+ label: notice.actionLabel,
+ onClick: () => {
+ void copyCrashReportDetails(copyFailure)
+ }
+ }
+ })
}
const dismissReportIfNeeded = async (): Promise => {
@@ -164,22 +181,7 @@ export function CrashReportDialogSurface({
githubEmail: null
})
if (!result.ok) {
- if (result.diagnosticBundle?.status === 'uploaded') {
- toast.error(
- translate(
- 'auto.components.crash.report.CrashReportDialog.b2e36f53a1',
- 'Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.',
- { value0: result.diagnosticBundle.ticketId }
- )
- )
- } else {
- toast.error(
- translate(
- 'auto.components.crash.report.CrashReportDialog.56a3dfa283',
- 'Failed to send crash report.'
- )
- )
- }
+ showSubmitFailure(result.error, result.diagnosticBundle)
console.error('Failed to submit crash report:', result.error)
return
}
@@ -188,17 +190,21 @@ export function CrashReportDialogSurface({
}
onReportChange(result.report)
setNotes('')
- toast.success(
- translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.')
- )
+ toast.dismiss(CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID)
+ const warningNotice = getCrashReportSubmitWarningNotice(result, includeDiagnosticLogs)
+ if (warningNotice) {
+ toast.warning(warningNotice.title, { description: warningNotice.description })
+ } else {
+ toast.success(
+ translate(
+ 'auto.components.crash.report.CrashReportDialog.8e24fe4f75',
+ 'Crash report sent.'
+ )
+ )
+ }
onOpenChange(false)
} catch (error) {
- toast.error(
- translate(
- 'auto.components.crash.report.CrashReportDialog.56a3dfa283',
- 'Failed to send crash report.'
- )
- )
+ showSubmitFailure(error)
console.error('Failed to submit crash report:', error)
} finally {
if (mountedRef.current) {
@@ -304,7 +310,13 @@ export function CrashReportDialogSurface({
-