diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 76595f338a6..3023d414abe 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -75,8 +75,12 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile + # Why: without --config, bare vitest ignores config/vitest.config.ts (there + # is no root config) and falls back to the 5s default timeout with no + # Windows worker cap, so the real csc.exe launcher-compile tests time out + # on hosted Windows. Use the shared config so this job matches pnpm test. - run: >- - pnpm vitest run + pnpm vitest run --config config/vitest.config.ts config/scripts/build-windows-cli-launcher.test.mjs src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47bf6b83a40..1e3fb4e6bdb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Why: the freshness registry is derived from immutable release tags, + # so shallow PR checkouts cannot verify historical official identities. + fetch-depth: 0 + persist-credentials: false - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh @@ -82,6 +87,9 @@ jobs: - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides + - name: Verify skill freshness manifest + run: pnpm run verify:skill-bundle-manifest + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml new file mode 100644 index 00000000000..6c9418e1f64 --- /dev/null +++ b/.github/workflows/skill-update-roundtrip.yml @@ -0,0 +1,50 @@ +name: Skill update round trip + +on: + pull_request: + paths: + - 'skills/**' + - 'resources/skills/**' + - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/skill-freshness.ts' + - '.github/workflows/skill-update-roundtrip.yml' + merge_group: + push: + branches: + - main + +jobs: + roundtrip: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + shape: [symlink, copy] + autocrlf: ['false', 'true'] + skills-cli: ['1.5.17'] + include: + - os: ubuntu-latest + shape: symlink + autocrlf: 'false' + skills-cli: latest + continue-on-error: ${{ matrix.skills-cli == 'latest' }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Verify targeted update convergence and copy behavior + env: + GITHUB_TOKEN: ${{ github.token }} + SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + run: >- + node config/scripts/verify-skill-update-roundtrip.mjs + --cli=${{ matrix.skills-cli }} + --autocrlf=${{ matrix.autocrlf }} + --shape=${{ matrix.shape }} diff --git a/README.md b/README.md index 81b9a5c5aea..d07692e1edd 100644 --- a/README.md +++ b/README.md @@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. +- **WeChat:** First WeChat group is full, now you can join the second one. - - + - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0dd6a3d85c7..98beb0c1c1f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,12 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: freshness detection needs immutable identity metadata from this exact +// app build, but never needs the skill package bytes or a runtime network read. +const skillFreshnessResources = { + from: 'resources/skills', + to: 'skills' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +37,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + ...packagedRuntimeNodeModuleResources, + skillFreshnessResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -66,6 +76,10 @@ module.exports = { // authoring sources too would duplicate content without a runtime consumer. '!skill-guides{,/**/*}', '!tests{,/**/*}', + // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE); + // it is gitignored, but exclude it defensively so a stray local capture at + // package time never bloats app.asar. + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js', @@ -75,7 +89,8 @@ module.exports = { '!tsconfig.json', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. - '!resources/onboarding/feature-wall/**' + '!resources/onboarding/feature-wall/**', + '!resources/skills/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ and local hook mutators from out/main/. These paths must be diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 9965e738a8a..448b4beac63 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -711,15 +711,18 @@ "ssh", "runtime" ], - "coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.", + "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.", "motivatingLinks": [ - "https://github.com/stablyai/orca/pull/8628" + "https://github.com/stablyai/orca/pull/8628", + "https://github.com/stablyai/orca/pull/8706" ], - "invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", - "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.", + "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", + "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", - "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", + "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", + "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" ], "testFiles": [ "src/main/agent-hooks/server-pane-authority.test.ts", @@ -727,12 +730,18 @@ "src/main/ipc/agent-pane-authority-ownership.test.ts", "src/main/ipc/pty-management.test.ts", "src/main/persistence.test.ts", + "src/main/pty-descendant-termination.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/runtime/worktree-teardown.test.ts", "src/renderer/src/store/slices/agent-pane-authority.test.ts", "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", - "tests/e2e/terminal-parked-close-retirement.spec.ts" + "tests/e2e/terminal-parked-close-retirement.spec.ts", + "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ { @@ -754,6 +763,39 @@ "exact pane retirement removes resume and launch authority while preserving siblings", "chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes" ] + }, + { + "file": "src/main/pty-descendant-termination.test.ts", + "assertions": [ + "20 same-turn process-table requests execute one fresh scan while later arrivals start one shared successor inside their own deadline", + "snapshot and escalation readers stop at their deadline", + "production ps reads force locale-independent C timestamps", + "the source scan timestamp survives request resolution and capture-second identities are never escalated with SIGKILL", + "cyclic-looking duplicate PID rows terminate with each descendant visited once and duplicate escalation identities stay unsignalled", + "descendant signals are suppressed after the caller loses root ownership" + ] + }, + { + "file": "src/main/daemon/terminal-host.test.ts", + "assertions": [ + "agent immediate kill rejects reattach while descendant capture is pending", + "a naturally exited session id remains reserved until capture finishes without force-killing its retired PID", + "graceful teardown upgrades to immediate both during and after descendant capture", + "duplicate immediate kill starts one descendant sweep" + ] + }, + { + "file": "src/main/runtime/worktree-teardown.test.ts", + "assertions": [ + "owned provider shutdowns start together so process-table snapshots can coalesce within a batch", + "inventories above 32 sessions never exceed 32 concurrent provider shutdowns" + ] + }, + { + "file": "tests/e2e/agent-descendant-process-kill.spec.ts", + "assertions": [ + "a detached-pgid descendant is alive before agent PTY kill and absent afterward" + ] } ], "evidenceRuns": [ @@ -765,11 +807,29 @@ "result": "passed", "durationSeconds": 40.3, "summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear." + }, + { + "date": "2026-07-14", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 37.7, + "summary": "A current-main integrated fresh-build run proved a detached-pgid child was alive before agent PTY kill and absent afterward on the deadline-safe, root-ownership-gated implementation." + }, + { + "date": "2026-07-15", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The cycle-safe, target-indexed, bounded-fanout review head passed from a cold full build; the live detached-pgid descendant test body completed in 4.8 seconds." } ], "runtimeBudget": { "p95Seconds": 60, - "scope": "fresh E2E build plus one isolated local Electron parked-close test" + "scope": "fresh E2E build plus isolated local Electron parked-close and descendant-kill tests" }, "flakeHistory": { "status": "unknown", @@ -780,8 +840,8 @@ "evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached." }, "performanceBudget": { - "required": false, - "evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup." + "required": true, + "evidence": "The close path is user-triggered and bounded by canonical live-owner indexing. Production ps has a 1s kill timeout; 20 same-turn requests execute one fresh process-table read, while requests arriving after a scan starts immediately share one successor so their deadline is not consumed waiting and no unusable ps starts after timeout. Completed tables are never reused. Bulk worktree shutdown runs in 32-wide batches so each batch can coalesce its initial scan without unbounded provider fanout. Descendant traversal uses a visited set and index cursor; a Node 24 local 100,000-wide synthetic tree fell from 861ms to 16.7ms, and escalation indexes only the requested descendant PIDs instead of duplicating the full process table. Escalation uses the same bounded coordinator, and kill-all store scale remains covered by terminal-session.kill-all-surface-cleanup." }, "promotionCriteria": [ "Accumulate 100 clean runs or 14 days on required CI platforms.", @@ -791,7 +851,10 @@ "knownGaps": [ "The live Electron proof currently covers macOS local PTYs only.", "Disconnected SSH relay death still requires reconnect-aware provider ownership.", - "Daemon owner leases and durable retry inventory remain follow-up hardening." + "Daemon owner leases and durable retry inventory remain follow-up hardening.", + "Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.", + "A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.", + "A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot." ], "demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority." }, @@ -1887,7 +1950,6 @@ "daemon stream", "main PTY batching", "runtime path provenance", - "runtime serve-sim detection", "runtime terminal wait detection", "renderer ACK", "xterm scheduler", @@ -1908,7 +1970,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", @@ -1916,17 +1978,15 @@ "https://github.com/stablyai/orca/pull/7054" ], "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", "src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "src/main/emulator/serve-sim-state-watcher.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], @@ -1946,13 +2006,6 @@ "copies history when new output adds a path candidate" ] }, - { - "file": "src/main/emulator/serve-sim-state-watcher.test.ts", - "assertions": [ - "does not buffer repeated brace-free PTY output while waiting for metadata", - "detects serve-sim metadata split across PTY chunks and releases the partial object" - ] - }, { "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", "assertions": [ @@ -1990,15 +2043,6 @@ "durationSeconds": 3.49, "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." }, - { - "date": "2026-07-10", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", - "result": "passed", - "durationSeconds": 0.37, - "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." - }, { "date": "2026-07-10", "runner": "local", @@ -2015,11 +2059,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." + "evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, @@ -2031,9 +2075,8 @@ "Keep stress variants non-blocking until stable runtime history exists." ], "knownGaps": [ - "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", - "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." @@ -3833,6 +3876,7 @@ "layer": "runtime-rpc-contract", "surfaces": [ "mobile terminal", + "native-chat terminal overlay", "runtime RPC", "binary multiplex", "JSON subscribe fallback", @@ -3854,21 +3898,25 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", + "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", - "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", + "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "testFiles": [ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/rpc/terminal-output-batching.test.ts", - "src/main/runtime/rpc/terminal-multiplex.test.ts" + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "assertionRefs": [ { @@ -3893,6 +3941,21 @@ "oversized live output frames are bounded for subscribed binary streams", "multibyte live output flushes when encoded bytes reach the batch budget" ] + }, + { + "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "an active terminal stream pauses while native chat covers its WebView", + "the stream resumes only for the ready active terminal after chat closes", + "non-terminal and already-settled stream states perform no lifecycle work" + ] + }, + { + "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "a late WebView-ready transition converts a cold-start lease-only stream back to output", + "ordinary WebView readiness does not rerender the mobile session route" + ] } ], "evidenceRuns": [ @@ -3904,6 +3967,15 @@ "result": "passed", "durationSeconds": 3.8, "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree." } ], "runtimeBudget": { @@ -3928,7 +4000,7 @@ "Record byte counts and order/cursor artifacts on failure." ], "knownGaps": [ - "Current command covers binary runtime/mobile stream contracts only.", + "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.", "Legacy JSON subscribe parity is undecided." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." @@ -4290,63 +4362,200 @@ "remote-runtime", "ssh" ], - "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool, a one-child healthy SSH relay pool, and at most four bounded quarantine children, with one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.", + "coverageNotes": "Deterministic tests cover one shared healthy child per runtime process plus at most four bounded fault-quarantine children, native desktop, paired-runtime, and WSL snapshot processes sharing the same eight-physical-child reservation with typed serialized event-driven capacity recovery, including recursive re-wait when crash recovery reclaims an announced slot, one quarantine attempt per watch lifetime, generation-scoped cancellation, bounded termination with removable deadline waiters, replacement-crawl errors that cannot be blessed by a late readiness ack, pre-ready paired-web cancellation registered before an unbounded capacity wait, already-resolved and late physical-exit retry of a rejected paired-web teardown, physical desktop-install and local/SSH PTY teardown, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. Destructive local and SSH removal fences both ID-derived and resolved-cwd terminal roots, including sibling-root/cwd combinations with reverse admission rollback, closes descendant relay watches before parent deletion, enumerates authoritative provider/cwd ownership, falls back to daemon spawn cwd before OSC 7, and keeps daemon/relay immediate shutdown pending until native exit or a bounded fail-closed error; a dead relay PID discovered during attach also settles concurrent shutdown before its stale entry is reaped, while Windows runtime watcher deadlines retain one removable physical-close waiter, treat Node's error-close path as positive physical-exit proof, and clear root ownership on late close. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. Abortable capacity, quarantine, runtime-root, relay, SSH, and relay pre-install setup waits attach one reaction to each shared promise and explicitly remove 10,000 cancelled caller closures while one anchor remains. Built-entry macOS harnesses kill the desktop/runtime and SSH relay watcher children, then require automatic resubscription and later events while the host process and relay PTY survive. SSH bundles the child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/5308", "https://github.com/stablyai/orca/issues/7547", "https://github.com/stablyai/orca/issues/8212" ], - "invariant": "Local runtime roots use at most four shared watcher children while healthy, while each SSH relay process uses at most one; either pool uses at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and each SSH target's relay, PTYs, Git, and other filesystem operations remain isolated from other targets and never load the watcher native addon or share its fault boundary.", - "oracle": "Fill the four-child healthy runtime pool with multiple roots, and map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor. Inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", + "invariant": "Local runtime and SSH relay roots share one watcher child per host process while healthy and use at most four quarantine children after shard failure; native desktop, paired-runtime, and direct WSL snapshot children consume the same process-wide eight-child reservation and retry transient exhaustion without permanently disabling a root. Cancellation remains owned by its child generation and settles only after acknowledgement or bounded physical exit; paired-web retains cleanup ownership and can cancel before readiness even while capacity is exhausted, retries a failed unwatch after already-resolved or late physical exit without duplicating stream termination, replacements never overlap a still-retiring generation, and aborted or timed-out callers detach immediately even while queued behind same-root relay teardown. Destructive local and SSH cleanup holds one containment-aware watcher/terminal removal fence through descendant watcher and PTY physical exit plus parent Git/filesystem deletion; daemon and relay kill requests retain ownership until the actual subprocess exit callback or an equivalent positive dead-PID proof and propagate rejected signals or a bounded failure otherwise. A crash-fused shared shard moves affected roots into the bounded quarantine pool, a second fused quarantine ends those watch lifetimes, interrupted setup emits overflow after recovery, metadata work stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.", + "oracle": "Fill the global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", + "node config/scripts/runtime-file-watcher-resource-probe.mjs", "node config/scripts/runtime-file-watcher-fault-harness.mjs", "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "pnpm run build:relay", "node config/scripts/relay-watcher-fault-harness.mjs" ], "testFiles": [ + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/ipc/filesystem-watcher.test.ts", + "src/main/ipc/filesystem-watcher-native-capacity.test.ts", + "src/main/ipc/parcel-watcher-child-registry.test.ts", + "src/main/ipc/parcel-watcher-child-launch.test.ts", "src/main/ipc/parcel-watcher-process.test.ts", + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts", + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts", + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts", + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts", + "src/main/ipc/runtime-watcher-pending-assignment.test.ts", "src/main/ipc/runtime-watcher-process-pool.test.ts", "src/main/ipc/parcel-watcher-process-entry.test.ts", "src/main/ipc/parcel-watcher-event-delivery.test.ts", "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", "src/main/ipc/filesystem-watcher-wsl.test.ts", + "src/main/ipc/worktrees.test.ts", + "src/main/ipc/watcher-removal-gate.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/daemon/terminal-host-session-reaping-leak.test.ts", "src/main/runtime/file-watcher-host.test.ts", + "src/main/runtime/orca-runtime-files-watch.test.ts", + "src/main/runtime/worktree-teardown.test.ts", + "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/methods/files.test.ts", + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", "src/main/providers/ssh-filesystem-provider.test.ts", + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", + "src/main/providers/ssh-pty-provider.test.ts", "src/renderer/src/runtime/runtime-file-client.test.ts", + "src/renderer/src/web/web-runtime-client.test.ts", + "src/shared/cross-platform-path.test.ts", + "src/shared/physical-exit-tracker.test.ts", + "src/shared/promise-settlement-waiters.test.ts", "config/scripts/runtime-file-watcher-fault-harness.mjs", + "config/scripts/runtime-file-watcher-resource-probe.mjs", "src/relay/relay-filesystem-watch-registry.test.ts", + "src/relay/relay-watcher-pending-setup-waiters.test.ts", + "src/relay/relay-watcher-parent-removal.test.ts", + "src/relay/relay-watcher-setup-wait.test.ts", "src/relay/fs-handler.test.ts", + "src/relay/pty-handler.test.ts", + "src/relay/git-handler.test.ts", "src/relay/subprocess.test.ts", "src/main/ssh/ssh-relay-versioned-install.test.ts", "config/scripts/package-electron-runtime-contract.test.mjs", "config/scripts/relay-watcher-fault-harness.mjs" ], "assertionRefs": [ + { + "file": "src/main/ipc/filesystem-watcher.test.ts", + "assertions": [ + "a transient WSL child-capacity rejection automatically retries on release and renderer unwatch cancels the wait", + "failed SSH worktree removal restores every live renderer listener, emits overflow, and resumes precise events" + ] + }, + { + "file": "src/main/ipc/filesystem-watcher-native-capacity.test.ts", + "assertions": [ + "typed native child-capacity exhaustion automatically retries after a serialized slot release", + "renderer unwatch cancels a pending native capacity retry before subscription restarts" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-child-registry.test.ts", + "assertions": [ + "capacity notification closes the release-before-listen race, supports cancellation, fires at most once, and serializes reserving waiters per released slot" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-child-launch.test.ts", + "assertions": [ + "native launcher reports typed transient capacity exhaustion without forking another child" + ] + }, + { + "file": "src/shared/physical-exit-tracker.test.ts", + "assertions": [ + "timed-out physical-exit waiters detach while active and later retries still settle on exact exit" + ] + }, + { + "file": "src/shared/promise-settlement-waiters.test.ts", + "assertions": [ + "one shared-promise reaction serves an anchor plus 10,000 abortable callers, and all cancelled closures detach immediately", + "timeout and already-aborted paths remove timers, listeners, and ownership while preserving the active anchor" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "daemon process enumeration falls back to spawn cwd before OSC 7 reports a live cwd", + "force-killed keep-history sleep remains cold-restorable across repeated wake cycles after physical exit" + ] + }, { "file": "src/main/ipc/parcel-watcher-process.test.ts", "assertions": [ "initial-subscribe crashes complete with an interruption refresh", + "watch errors before readiness reject setup and physically cancel the native crawl", + "watch errors during replacement crawl terminate recovery and a late readiness ack cannot revive the failed root", "IPC disconnect recovery restores every existing root before accepting new setup", "queued abort removes one request without restarting healthy roots, while active abort or crawl-scoped timeout restores the shard", - "a crash-resubscribe crawl deadline ends only the stuck root and tears down its shard process", + "a crash-resubscribe crawl deadline and concurrent unsubscribe settle only after physical shard exit, or report physical-exit failure at the termination deadline", + "a close-only asynchronous spawn failure settles physical termination without waiting for the exit deadline", + "production runtime-pool disposal rejects later subscriptions without forking an overlapping child", + "Vitest cleanup resets rather than re-disposes the reusable runtime pool", + "native child launch preserves typed transient capacity exhaustion instead of reporting permanent process unavailability", "missing built entries fail closed and unavailable canary storage remains optional", "fault-harness PID reporting cannot clobber an existing filesystem path" ] }, + { + "file": "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts", + "assertions": [ + "a canary-independent native-unsubscribe deadline restarts the shard and restores healthy siblings after physical child exit", + "an unkillable child rejects the native unsubscribe with its retained physical-exit promise only at the bounded exit deadline" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts", + "assertions": [ + "10,000 cancelled capacity callers leave exactly one live anchor and zero waiters after the released slot settles" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts", + "assertions": [ + "one released physical slot launches one shared supervisor child for every waiting root, while partial/all cancellation and disposal remove the shared capacity barrier", + "a waiter recursively re-enters the event-driven barrier when crash recovery reclaims its announced slot" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-disconnect-termination.test.ts", + "assertions": [ + "pending-crawl cancellation after disconnect remains pending until the exact child exits", + "ready and pending-crawl cancellation force disconnected-child exit even while a healthy sibling root remains", + "a cancellation deadline joins active disconnect termination without duplicate sibling resubscription", + "an exit-deadline failure replaces the early cancellation and remains visible to synchronous unsubscribe until physical exit" + ] + }, + { + "file": "src/main/ipc/runtime-watcher-pending-assignment.test.ts", + "assertions": [ + "10,000 cancelled quarantine-assignment callers leave exactly one live anchor and zero waiters after grant", + "an already-aborted first caller synchronously releases pending assignment ownership" + ] + }, { "file": "src/main/ipc/runtime-watcher-process-pool.test.ts", "assertions": [ - "healthy roots spread across at most four shared supervisors before sharing", + "healthy roots share one supervisor until a fault requires quarantine", "a supervisor-scoped failure spreads affected roots across at most four quarantine supervisors", "a second fused quarantine creates no further replacement for that watch lifetime", "a root-specific failure preserves healthy shard assignments", "a live root whose crash-resubscribe times out moves to quarantine", - "an older same-root teardown cannot erase a newer watch lease's assignment" + "an older same-root teardown cannot erase a newer watch lease's assignment", + "test reset explicitly reopens the production-disposed pool", + "a zero-waiter aborted assignment cannot poison a same-turn replacement subscribe" ] }, { @@ -4371,15 +4580,126 @@ "file": "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", "assertions": [ "worktree deletion physically aborts pending native setup and settles before its crawl resolves", - "app shutdown physically aborts pending native setup and releases a late native handle" + "app shutdown physically aborts pending native setup and releases a late native handle", + "a cancelled pending install retains physical-exit failure across an immediate second destructive close", + "desktop terminal failure remains root-owned even when the cleared subscription later unsubscribes cleanly", + "Windows drive and UNC watcher ownership is found across path casing and separator variants", + "Windows event payloads retain the physical root spelling instead of the comparison key", + "failed local worktree removal restores every live renderer listener and emits overflow" + ] + }, + { + "file": "src/main/ipc/filesystem-watcher-wsl.test.ts", + "assertions": [ + "unsubscribe and startup abort remain pending until the WSL watcher process physically closes", + "a termination timeout or rejected kill retains the WSL watcher owner for later physical-exit proof", + "WSL watcher processes consume the shared eight-child reservation until physical close, including synchronous startup-write cleanup" + ] + }, + { + "file": "src/main/ipc/worktrees.test.ts", + "assertions": [ + "desktop IPC deletion never starts Git removal when physical PTY teardown cannot be proven", + "normal and orphan/leftover deletion paths perform strict PTY teardown before local or SSH Git/filesystem removal", + "SSH deletion fails closed before Git when the remote PTY provider is unavailable", + "failed IPC deletion finishes the watcher fence as aborted so logical subscriptions can recover" + ] + }, + { + "file": "src/main/ipc/watcher-removal-gate.test.ts", + "assertions": [ + "Windows-equivalent spellings share one removal fence while distinct POSIX backslash roots do not", + "desktop and runtime removal entry points cannot own overlapping destructive roots concurrently", + "parent removal waits descendant terminal spawns and rejects later descendant installs" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "renderer PTY spawn is rejected before provider creation while destructive worktree removal holds the shared terminal gate", + "renderer and runtime spawns fence both the worktree-ID root and resolved cwd, including cross-worktree cwd ownership with rollback", + "manual and runtime-controller local PTY shutdown retain listener and provider ownership until physical exit", + "did-finish-load orphan cleanup disposes data delivery but retains the onExit listener and tracker through physical exit", + "a failed local PTY kill retains retryable provider ownership until a later physical exit" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "local shutdown remains pending and owned until node-pty reports physical exit", + "local process enumeration retains the spawn cwd for destructive ownership fallback", + "destructive cleanup escalates an existing graceful shutdown without losing its shared exit proof", + "POSIX graceful shutdown escalates to one bounded SIGKILL when the child ignores SIGTERM", + "Windows graceful-to-destructive cleanup treats the first ConPTY kill as final and never double-closes the handle", + "orphan cleanup racing immediate shutdown shares one kill and remains owned until the queued physical exit", + "timeout and kill failure preserve ownership for a later successful retry" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "rejected graceful and force kills propagate without marking the wrapper dead so retries can signal the same child" + ] + }, + { + "file": "src/main/daemon/session.test.ts", + "assertions": [ + "graceful daemon termination force-kills after its deadline but retains session ownership and late output until physical exit", + "rejected graceful and force kills reopen termination state for a later retry" + ] + }, + { + "file": "src/main/daemon/terminal-host.test.ts", + "assertions": [ + "immediate daemon kill remains pending and listed until the subprocess exit callback", + "an unconfirmed immediate kill rejects at the bounded deadline, retains ownership, and blocks same-id replacement" + ] + }, + { + "file": "src/main/daemon/terminal-host-session-reaping-leak.test.ts", + "assertions": [ + "immediate daemon kill reaps the emulator and session only after physical exit" ] }, { "file": "src/main/runtime/file-watcher-host.test.ts", "assertions": [ "same-root clients share one physical watch and one client abort cannot cancel another owner's setup", + "concurrent pre-ready cancellations join one native root release before an immediate replacement subscribes", + "10,000 cancelled runtime-root callers detach while one setup anchor remains", + "root-scoped destructive cleanup joins an in-progress subscriber teardown", "shard failure preserves existing subscribers through isolated recovery and rejects stale-generation events", - "only failed isolated recovery ends subscribers, after a conservative overflow" + "only failed isolated recovery ends subscribers, after a conservative overflow", + "initial setup failure remains discoverable by root-scoped destructive cleanup until physical exit" + ] + }, + { + "file": "src/main/runtime/orca-runtime-files-watch.test.ts", + "assertions": [ + "Windows runtime cleanup waits for the native close event, fails closed at a bounded deadline, and clears retained ownership on late close without retry listeners", + "a Windows watcher error before cleanup uses Node's proven native error-close as physical-exit proof, emits final overflow and terminal error, and releases deletion ownership", + "pre-publication local runtime setup failure remains owned through destructive cleanup", + "failed deletion re-arms runtime watches immediately after ordinary teardown or after delayed physical exit, without reviving a logically stopped watch" + ] + }, + { + "file": "src/main/runtime/worktree-teardown.test.ts", + "assertions": [ + "overlapping runtime, provider, and registry sweeps stay behind one physical PTY-stop promise", + "destructive teardown rejects instead of releasing deletion when physical stop misses its deadline", + "cwd-owned and floating PTYs are swept while legacy rows without cwd retain prefix ownership", + "remote provider enumeration matches authoritative worktree ownership without touching the local-only registry" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "destructive worktree teardown awaits controller stopAndWait instead of treating fire-and-forget kill as physical exit", + "runtime worktree deletion never starts Git removal when a known PTY cannot be physically stopped", + "SSH runtime deletion shuts down an authoritatively owned remote PTY before remote Git removal", + "a delayed physical-exit retry remains scoped to its registered cleanup owner and cannot tear down a same-id replacement", + "failed CLI worktree deletion restores watcher ownership after releasing the install fence", + "cross-layer teardown failure joins every bounded watcher close before restoration begins" ] }, { @@ -4389,12 +4709,43 @@ "terminal watcher loss flushes a final overflow change before error/end and unregisters cleanup" ] }, + { + "file": "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", + "assertions": [ + "the server publishes a cleanup token before ready and files.unwatch aborts pending setup without delivering queued crawl events" + ] + }, + { + "file": "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", + "assertions": [ + "a failed paired-web unwatch retries after already-resolved or late physical exit and emits stream end exactly once" + ] + }, + { + "file": "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", + "assertions": [ + "10,000 cancelled SSH callers detach while one registration-owned setup anchor remains" + ] + }, { "file": "src/main/providers/ssh-filesystem-provider.test.ts", "assertions": [ "an SSH caller abort rejects promptly without cancelling another same-root subscriber's shared setup", "the physical relay request is cancelled when the last pending subscriber leaves", - "provider disposal cancels pending setup and sends one remote unwatch" + "provider disposal cancels pending setup and sends one remote unwatch", + "equivalent Windows spellings share one provider generation and terminal failure reaches the surviving owner" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "remote process enumeration preserves authoritative worktree ownership while scoping relay PTY ids to the SSH connection" + ] + }, + { + "file": "src/shared/cross-platform-path.test.ts", + "assertions": [ + "comparison keys preserve literal POSIX backslashes while folding Windows drive and UNC spellings" ] }, { @@ -4406,6 +4757,25 @@ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" ] }, + { + "file": "src/relay/relay-watcher-parent-removal.test.ts", + "assertions": [ + "POSIX and Windows parent deletion closes and terminally notifies descendant watches before removal while preserving path siblings", + "parent deletion waits for descendant setup publication and already-retiring descendant teardown before removal" + ] + }, + { + "file": "src/relay/relay-watcher-pending-setup-waiters.test.ts", + "assertions": [ + "10,000 aborted relay setup callers detach while one same-root teardown anchor remains and only one replacement installs" + ] + }, + { + "file": "src/relay/relay-watcher-setup-wait.test.ts", + "assertions": [ + "10,000 cancelled relay setup callers leave exactly one explicit anchor and one base-promise reaction" + ] + }, { "file": "src/relay/relay-filesystem-watch-registry.test.ts", "assertions": [ @@ -4414,19 +4784,48 @@ "replacement emits overflow before later ordered events", "terminal shard failure preserves same-root clients through recovery", "pending setup aborts only after the last same-root client leaves", + "relay-side removal sends every client a terminal failure, closes every watch, and fences new same-root watcher or PTY setup even when deletion fails", "a missing child fails closed even if the relay inherits a VITEST environment" ] }, { "file": "src/relay/fs-handler.test.ts", "assertions": [ - "shared watches retain the twenty-root budget and release after every client leaves" + "shared watches retain the twenty-root budget and release after every client leaves", + "recursive relay deletion closes and terminally notifies same-root watches before removing the directory" + ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "immediate relay shutdown stays pending with the PTY map entry retained until onExit", + "a missing exit rejects at the bounded deadline, preserves the physical owner, and a retry joins without duplicate SIGKILL", + "Windows relay graceful, fallback, immediate, stale-spawn, exit-dispose, and handler-dispose paths issue at most one ConPTY kill", + "relay PTY admission succeeds when post-Node-18 array copy methods are unavailable", + "attach dead-PID proof settles a concurrent immediate shutdown before the stale PTY entry is reaped", + "concurrent spawn and revive admission is atomic, capped at fifty physical PTYs, and same-id revive is deduplicated", + "worktree removal waits explicit and default-cwd PTY creation, rejects later creation, and physically stops only authoritative matching PTYs before deletion", + "relay spawn and revive fence distinct worktree-ID and cwd roots, rolling back earlier admissions in reverse when either root is removing" + ] + }, + { + "file": "src/renderer/src/web/web-runtime-client.test.ts", + "assertions": [ + "a stopped pre-ready paired-web watch retains its callback beyond the former deadline and uses the early server token to unwatch", + "transient shared-socket loss replays an active file watch under a new transport id while preserving its logical handle and never revives a stopped watch" + ] + }, + { + "file": "src/relay/git-handler.test.ts", + "assertions": [ + "remote Git worktree removal executes inside the relay filesystem watcher fence" ] }, { "file": "src/relay/subprocess.test.ts", "assertions": [ - "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child" + "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child", + "the Node-18-targeted relay bundle contains no unsupported array copy methods" ] }, { @@ -4441,6 +4840,15 @@ "every release platform packages and gates the hashed watcher child" ] }, + { + "file": "config/scripts/runtime-file-watcher-resource-probe.mjs", + "assertions": [ + "five healthy roots share one exact watcher PID within the per-child RSS budget", + "four quarantine roots use exactly four child PIDs and remain within the global eight-child reservation cap", + "CPU and RSS are sampled for every exact child PID and every PID exits during cleanup", + "per-child and aggregate RSS and sampled CPU ceilings fail the probe before the physical child cap can hide excess resource use" + ] + }, { "file": "config/scripts/runtime-file-watcher-fault-harness.mjs", "assertions": [ @@ -4459,13 +4867,202 @@ ], "evidenceRuns": [ { - "date": "2026-07-12", + "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 4.5, - "summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests." + "durationSeconds": 11.98, + "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 11.59, + "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.58, + "summary": "Five healthy roots shared one child at 50,688 KiB RSS and 4.9% sampled CPU; four quarantine children totaled 202,368 KiB RSS and 29.5% sampled CPU, all budgets and the eight-child cap passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 10.01, + "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.56, + "summary": "Five healthy roots shared one child at 50,176 KiB RSS and 6.0% sampled CPU; four quarantine children totaled 200,112 KiB RSS and 18.0% sampled CPU. The global eight-child cap and all per-child/aggregate budgets passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "result": "passed", + "durationSeconds": 10.01, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:electron-vite", + "result": "passed", + "durationSeconds": 24.41, + "summary": "The production Electron main, preload, renderer, daemon, and watcher child entries built successfully." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.1, + "summary": "The Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.46, + "summary": "The Electron-as-Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:relay", + "result": "passed", + "durationSeconds": 0.38, + "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.12, + "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "result": "passed", + "durationSeconds": 9.12, + "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.56, + "summary": "Five healthy roots shared one child at 50,096 KiB RSS and 3.4% sampled CPU; four quarantine children totaled 200,576 KiB RSS and 25.0% sampled CPU. The eight-child physical cap and every per-child/aggregate CPU and RSS budget passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "result": "passed", + "durationSeconds": 10.69, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests, including watcher/PTY removal fencing and recursive deletion failure behavior." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:electron-vite", + "result": "passed", + "durationSeconds": 24, + "summary": "The production Electron main, preload, renderer, daemon, and watcher child entries built successfully." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.01, + "summary": "The Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.46, + "summary": "The Electron-as-Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:relay", + "result": "passed", + "durationSeconds": 0.39, + "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.12, + "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "result": "passed", + "durationSeconds": 9.03, + "summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.55, + "summary": "Five healthy roots shared one child at 50,160 KiB RSS and 5.6% sampled CPU; four quarantine children totaled 199,968 KiB RSS and 16.7% sampled CPU, hard per-child and aggregate RSS/CPU budgets passed, the eight-child reservation cap rejected the next launch, and every exact PID exited during cleanup." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "result": "passed", + "durationSeconds": 9.79, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 225 tests, including terminal removal notification and recursive deletion fencing." }, { "date": "2026-07-12", @@ -4477,13 +5074,13 @@ "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." }, { - "date": "2026-07-12", + "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "result": "passed", - "durationSeconds": 1.7, - "summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 223 tests." + "durationSeconds": 8.85, + "summary": "18 focused supervisor, disconnect termination, pool, child-entry, delivery, desktop/WSL cleanup and IPC removal, removal-gate, runtime-host/commands and physical PTY teardown, RPC, SSH ownership, renderer/paired-web clients, and cross-platform path files passed with 1,193 tests." }, { "date": "2026-07-12", @@ -4527,7 +5124,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Local runtime operation remains capped at four shared child processes, while each SSH relay is capped at one healthy child instead of spreading a standard repository's three roots across three children. Based on the measured 51.9 MiB per child, normal SSH watcher overhead is bounded near 51.9 MiB rather than 155.7 MiB before relay and PTY overhead. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations for local runtime; 8 healthy or 40 total per SSH relay), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added." + "evidence": "The committed resource probe reproducibly measures five healthy roots sharing one child (50,160 KiB RSS, 5.6% sampled CPU) and four quarantine children (199,968 KiB total RSS, 16.7% sampled CPU), enforces 128 MiB/50% per-child and 512 MiB/100% quarantine aggregate budgets, derives a 1 GiB/400% ceiling across the global eight-physical-child cap, and waits for every exact PID during cleanup. A relay policy test separately proves a standard SSH repository's three roots share one healthy child. Shared pending setup attaches one base-promise reaction per generation; quantitative owner tests abort 10,000 callers at each affected layer and require exactly one anchor, then zero explicit waiters after settlement. The shared supervisor capacity barrier is event-driven, one-shot, abortable, and serialized per released physical slot, waking every same-child desktop, paired-runtime, or SSH relay root without consuming crash-recovery attempts; direct WSL capacity retains the same event-driven desktop retry. Relay PTY admission adds at most two deduplicated path checks per spawn/revive and releases both without polling or retained listeners, while dead-PID reaping settles an existing tracker without allocating another timer. Fault quarantine distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most eight healthy or 32 quarantined operations), each subscription retains at most one active plus one bounded pending batch, and no additional polling or serve-process stat fanout is added." }, "promotionCriteria": [ "Run the built-entry SIGSEGV harness in soak for at least 100 consecutive passes or 14 days on macOS.", @@ -4536,11 +5133,11 @@ ], "knownGaps": [ "The fault harness bundles the real source supervisor and drives the built child, but it does not launch a packaged orca serve pairing session or exercise app.asar path resolution.", - "The one-child SSH healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.", + "The committed resource probe covers the one-child healthy pool, four-child fault-quarantine ceiling, RSS/CPU sampling, global physical-child cap, and exact-PID cleanup, but sustained six-writer event-loop-delay and CPU soak evidence is still absent.", "Linux native watcher fault containment is source-equivalent but not live fault-injected in this evidence run.", - "Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected." + "Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected." ], - "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four local runtime watcher children or one child in any SSH relay, if total healthy plus quarantine operation exceeds eight locally or five in an SSH relay, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." + "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." } ] } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 4af5a68dd5a..0d14e2373b6 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -4,9 +4,14 @@ import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' -const itWindows = process.platform === 'win32' ? it : it.skip const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') +// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; +// keep the larger allowance scoped to the real compiler integration test. +function itWindows(name, test) { + const runner = process.platform === 'win32' ? it : it.skip + runner(name, { timeout: 15_000 }, test) +} describe('Windows CLI launcher', () => { itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7d4fd9af56b..d5671498468 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -29,7 +29,9 @@ describe('electron-builder config', () => { '!native{,/**/*}', '!skills{,/**/*}', '!skill-guides{,/**/*}', + '!resources/skills/**', '!tests{,/**/*}', + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js' @@ -38,6 +40,12 @@ describe('electron-builder config', () => { }) it('keeps runtime resources available through extraResources', () => { + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'resources/skills', + to: 'skills' + }) + } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 00000000000..c3542d6f0e2 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -0,0 +1,522 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' + +const SCHEMA_VERSION = 1 +const SCRIPT_DIR = import.meta.dirname +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') +const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') +const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') +const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') +const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') +const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function gitObjectSha(kind, bytes) { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +function normalizeText(bytes) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +function classifyFile(bytes) { + if (bytes.includes(0)) { + return 'binary' + } + try { + normalizeText(bytes) + return 'text' + } catch { + return 'binary' + } +} + +function assertSafeRelativePath(relativePath) { + if ( + path.isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) + ) { + throw new Error(`Unsafe skill package path: ${relativePath}`) + } +} + +function describeFile(manifestPath, bytes, executable) { + const classification = classifyFile(bytes) + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + return { + path: manifestPath, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + } +} + +function gitTreeSha(entries) { + const root = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, ...entry }) + } + + function hashDirectory(directory) { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: Buffer.from(file.gitBlobSha, 'hex') + })) + ].sort((left, right) => { + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} + +async function collectPackageFiles(packageRoot) { + const files = [] + const caseFoldedPaths = new Map() + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + // Why: build-time Node and packaged Electron may ship different ICU data; + // package identity order must use the same locale-independent comparison. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = path.relative(packageRoot, absolutePath) + assertSafeRelativePath(relativePath) + const manifestPath = relativePath.split(path.sep).join('/') + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) + } + if (fileStat.isDirectory()) { + await visit(absolutePath) + continue + } + if (!fileStat.isFile()) { + throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) + } + // Why: Windows observation cannot see execute bits, so an executable file in + // a shipped skill would misclassify every pristine Windows install as unrecognized. + if ((fileStat.mode & 0o111) !== 0) { + throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) + } + files.push(describeFile(manifestPath, await readFile(absolutePath), false)) + } + } + + await visit(packageRoot) + return sortManifestFiles(files) +} + +function collectGitSkillTreeEntries(treeSha) { + const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) + .toString('utf8') + .split('\0') + .filter(Boolean) + const packages = new Map() + for (const line of output) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) + if (!match) { + throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) + } + const [, mode, type, objectSha, sourcePath] = match + const separator = sourcePath.indexOf('/') + if (separator <= 0 || separator === sourcePath.length - 1) { + throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) + } + const name = sourcePath.slice(0, separator) + const manifestPath = sourcePath.slice(separator + 1) + const entries = packages.get(name) ?? [] + entries.push({ mode, type, objectSha, manifestPath }) + packages.set(name, entries) + } + return packages +} + +function readGitBlobs(objectShas) { + const uniqueShas = [...new Set(objectShas)] + if (uniqueShas.length === 0) { + return new Map() + } + // Why: released history spans hundreds of tags. Batch mode avoids a Git + // subprocess per historical file while remaining available on Git 2.25. + const output = execFileSync('git', ['cat-file', '--batch'], { + input: `${uniqueShas.join('\n')}\n`, + maxBuffer: 64 * 1024 * 1024 + }) + const blobs = new Map() + let offset = 0 + for (const requestedSha of uniqueShas) { + const headerEnd = output.indexOf(10, offset) + if (headerEnd < 0) { + throw new Error(`Missing git cat-file header for ${requestedSha}`) + } + const header = output.subarray(offset, headerEnd).toString('utf8') + const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) + if (!match || match[1] !== requestedSha) { + throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) + } + const size = Number(match[2]) + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (contentEnd >= output.length || output[contentEnd] !== 10) { + throw new Error(`Truncated git blob for ${requestedSha}`) + } + blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) + offset = contentEnd + 1 + } + return blobs +} + +function collectGitPackageFiles(treeSha, name, entries, blobs) { + const caseFoldedPaths = new Map() + const files = entries.map(({ mode, type, objectSha, manifestPath }) => { + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) + } + assertSafeRelativePath(manifestPath) + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const bytes = blobs.get(objectSha) + if (!bytes) { + throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) + } + return describeFile(manifestPath, bytes, mode === '100755') + }) + // Why: git ls-tree emits git byte-order, not the canonical walk order. + return sortManifestFiles(files) +} + +// Why: snapshot matching compares files by array index, so every producer — +// working-tree walk, git history, and runtime observation — must emit one +// canonical order. This mirrors the sorted depth-first filesystem walk. +function compareManifestPaths(left, right) { + const leftParts = left.split('/') + const rightParts = right.split('/') + const shared = Math.min(leftParts.length, rightParts.length) + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(leftParts[index], rightParts[index]) + if (order !== 0) { + return order + } + } + return leftParts.length - rightParts.length +} + +function sortManifestFiles(files) { + return [...files].sort((left, right) => compareManifestPaths(left.path, right.path)) +} + +function packageDigest(files) { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ), + 'utf8' + ) + ) +} + +function releaseTags() { + return execFileSync( + 'git', + ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], + { encoding: 'utf8' } + ) + .split('\n') + .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) +} + +function skillsTreeShasAtRefs(refs) { + if (refs.length === 0) { + return [] + } + const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { + input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, + encoding: 'utf8' + }) + const lines = output.trimEnd().split('\n') + if (lines.length !== refs.length) { + throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) + } + return lines.map((line, index) => { + if (line.endsWith(' missing')) { + return null + } + const match = /^([a-f0-9]+) tree$/.exec(line) + if (!match) { + throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) + } + return match[1] + }) +} + +function buildReleasedHistory() { + const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const tags = releaseTags() + const treeShas = skillsTreeShasAtRefs(tags) + const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] + const packagesByTree = new Map( + distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) + ) + const blobs = readGitBlobs( + [...packagesByTree.values()].flatMap((packages) => + [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) + ) + ) + let previousSkillsTreeSha = null + for (const [index, tag] of tags.entries()) { + const skillsTreeSha = treeShas[index] + if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { + continue + } + previousSkillsTreeSha = skillsTreeSha + const revisions = {} + const packages = packagesByTree.get(skillsTreeSha) + if (!packages) { + throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) + } + for (const name of [...packages.keys()].sort(compareCodeUnits)) { + const entries = packages.get(name) + const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + continue + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshots.push({ + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + }) + registry.skills[name] = snapshots + } + revisions[name] = snapshots.at(-1).releaseRevision + } + if (Object.keys(revisions).length > 0) { + mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) + } + } + return { registry, mapping } +} + +async function buildArtifacts(appVersion) { + const { registry, mapping } = buildReleasedHistory() + const releasedSnapshotCounts = Object.fromEntries( + Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) + ) + const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareCodeUnits) + const currentSkills = [] + for (const name of skillDirectories) { + const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + throw new Error(`Skill package ${name} has no top-level SKILL.md`) + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + let snapshot = latest + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshot = { + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + } + snapshots.push(snapshot) + registry.skills[name] = snapshots + } + currentSkills.push({ + name, + sourcePath: `skills/${name}`, + appVersion, + ...snapshot + }) + } + return { + currentManifest: { + schemaVersion: SCHEMA_VERSION, + appVersion, + skills: currentSkills + }, + snapshotRegistry: registry, + releaseMapping: mapping, + releasedSnapshotCounts + } +} + +// Why: released snapshots are the detection ground truth for existing installs, +// so a generation-logic change must not rewrite them silently. Only the one +// unreleased working-tree append per skill may change between runs. +function assertReleasedHistoryPreserved(committedRegistry, artifacts) { + if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + return + } + for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { + const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 + const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } + const protectedCount = Math.min(committedSnapshots.length, releasedCount) + for (let index = 0; index < protectedCount; index += 1) { + const committed = committedSnapshots[index] + const rebuilt = regenerated[index] + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { + throw new Error( + `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + + 'Released snapshots are append-only; a deliberate identity migration must update this check.' + ) + } + } + } +} + +async function readCommittedRegistry() { + try { + return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) + } catch { + return null + } +} + +function serialized(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +async function writeArtifacts(artifacts) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await Promise.all([ + writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)), + writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)), + writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping)) + ]) +} + +async function verifyArtifacts(artifacts) { + const expected = [ + [CURRENT_MANIFEST_PATH, artifacts.currentManifest], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + ] + const stale = [] + for (const [filePath, value] of expected) { + try { + await access(filePath, constants.R_OK) + if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + stale.push(filePath) + } + } catch { + stale.push(filePath) + } + } + if (stale.length > 0) { + throw new Error( + `Generated skill artifacts are stale:\n${stale + .map((filePath) => path.relative(REPO_ROOT, filePath)) + .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + ) + } +} + +async function main() { + const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const artifacts = await buildArtifacts(packageJson.version) + assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) + await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} + +export { + assertReleasedHistoryPreserved, + buildArtifacts, + buildReleasedHistory, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles, + verifyArtifacts, + writeArtifacts +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 00000000000..cf4a8a100e5 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertReleasedHistoryPreserved, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles +} from './generate-skill-bundle-manifest.mjs' + +const temporaryDirectories = [] + +async function createPackage() { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle manifest generator', () => { + it('folds platform line endings for text identity', () => { + const lf = Buffer.from('first\nsecond\n') + const crlf = Buffer.from('first\r\nsecond\r\n') + + expect(classifyFile(lf)).toBe('text') + expect(normalizeText(crlf)).toEqual(lf) + }) + + it('classifies null-containing and invalid UTF-8 content as binary', () => { + expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary') + expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary') + }) + + it('uses normalized text identity but exact executable identity', () => { + const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false) + const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true) + + expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256) + expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256) + expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) + }) + + it('orders git-history files identically to the filesystem walk', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + const walked = await collectPackageFiles(packageRoot) + + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based + // snapshot matching requires history and observation to share one order. + const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => + walked.find((file) => file.path === manifestPath) + ) + + expect(sortManifestFiles(gitOrdered)).toEqual(walked) + expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked)) + expect(walked.map((file) => file.path)).toEqual([ + 'Zebra.md', + 'apple.md', + 'sub/inner.txt', + 'sub.md' + ]) + }) + + it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { + const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) + const artifacts = { + releasedSnapshotCounts: { 'orca-cli': 2 }, + snapshotRegistry: { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } + } + } + + expect(() => + assertReleasedHistoryPreserved( + { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } + }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') + expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects executable files in shipped skill packages', + async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n') + await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n') + await chmod(path.join(packageRoot, 'run.sh'), 0o755) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Executable file is not allowed in a shipped skill: run.sh' + ) + } + ) + + it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await writeFile(path.join(packageRoot, 'Readme.md'), 'one') + await writeFile(path.join(packageRoot, 'README.md'), 'two') + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths') + }) + + it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await symlink('SKILL.md', path.join(packageRoot, 'linked.md')) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + }) + + it('computes the same Git tree identity as Git', async () => { + const packageRoot = path.resolve('skills', 'orca-cli') + const files = await collectPackageFiles(packageRoot) + const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], { + encoding: 'utf8' + }) + .trim() + .split(/\s+/)[2] + + expect(gitTreeSha(files)).toBe(expected) + }) + + it('matches Git when a directory and file share a name prefix', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') + await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') + const files = await collectPackageFiles(packageRoot) + execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) + execFileSync('git', ['add', '-A'], { cwd: packageRoot }) + const expected = execFileSync('git', ['write-tree'], { + cwd: packageRoot, + encoding: 'utf8' + }).trim() + + expect(gitTreeSha(files)).toBe(expected) + }) +}) diff --git a/config/scripts/runtime-file-watcher-resource-probe.mjs b/config/scripts/runtime-file-watcher-resource-probe.mjs new file mode 100644 index 00000000000..67d2df02a63 --- /dev/null +++ b/config/scripts/runtime-file-watcher-resource-probe.mjs @@ -0,0 +1,257 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { build } from 'esbuild' + +const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js') +const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts') +const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts') +const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts') +const MAX_CHILD_RSS_KIB = 128 * 1024 +const MAX_CHILD_CPU_PERCENT = 50 +const MAX_QUARANTINE_RSS_KIB = 512 * 1024 +const MAX_QUARANTINE_CPU_PERCENT = 100 +const PHYSICAL_CHILD_CAP = 8 +const WAIT_TIMEOUT_MS = 15_000 +const execFileAsync = promisify(execFile) +const require = createRequire(import.meta.url) + +async function main() { + if (!existsSync(ENTRY_PATH)) { + throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`) + } + + const createdRoots = [] + const exactPids = new Set() + let bundleDir + let pool + let result + try { + bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-')) + const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } = + await loadProbe(bundleDir) + const roots = [] + for (let index = 0; index < 5; index++) { + const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`)) + createdRoots.push(createdRoot) + roots.push(await realpath(createdRoot)) + } + + pool = new RuntimeWatcherProcessPool() + await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))) + const healthyPids = activeChildPids(pool) + assertPidCount(healthyPids, 1, 'five healthy roots') + healthyPids.forEach((pid) => exactPids.add(pid)) + const healthyResources = await sampleResources(healthyPids) + assertResourceBudget(healthyResources, 'healthy pool', { + maxTotalRssKiB: MAX_CHILD_RSS_KIB, + maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT + }) + assertRegistryCapacity(reserveWatcherChild, 7) + + const sharedSlot = [...pool.activeSlots][0] + const failure = new WatcherProcessFailure( + 'resource probe synthetic shared-shard fault', + 'supervisor', + 'supervisor_crash_fuse' + ) + for (const record of sharedSlot.supervisor.records.values()) { + record.hooks.onTerminalError?.(failure) + } + await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit') + + await Promise.all( + roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})) + ) + const quarantinePids = activeChildPids(pool) + assertPidCount(quarantinePids, 4, 'four fault-quarantine roots') + quarantinePids.forEach((pid) => exactPids.add(pid)) + const quarantineResources = await sampleResources(quarantinePids) + assertResourceBudget(quarantineResources, 'quarantine pool', { + maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB, + maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT + }) + // Four live quarantine children leave four reservations before the global + // eight-physical-child ceiling rejects the next launch. + assertRegistryCapacity(reserveWatcherChild, 4) + + result = { + hostPid: process.pid, + healthy: summarizeResources(healthyResources), + quarantine: summarizeResources(quarantineResources), + physicalChildCap: PHYSICAL_CHILD_CAP, + budgets: { + maxChildRssKiB: MAX_CHILD_RSS_KIB, + maxChildCpuPercent: MAX_CHILD_CPU_PERCENT, + maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB, + maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT, + maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP, + maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP + }, + exactPidCleanup: true + } + } finally { + pool?.dispose() + await Promise.all( + [...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`)) + ) + await Promise.all([ + ...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })), + bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve() + ]) + } + console.log(JSON.stringify(result)) +} + +async function loadProbe(bundleDir) { + const outfile = join(bundleDir, 'watcher-resource-probe.cjs') + await build({ + stdin: { + contents: [ + `export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`, + `export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`, + `export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}` + ].join('\n'), + resolveDir: process.cwd(), + sourcefile: 'runtime-file-watcher-resource-probe-entry.ts', + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + external: ['@parcel/watcher', 'electron'], + logLevel: 'silent' + }) + return require(outfile) +} + +function activeChildPids(pool) { + return [...pool.activeSlots] + .map((slot) => slot.supervisor.child?.pid) + .filter((pid) => Number.isInteger(pid)) +} + +function assertPidCount(pids, expected, label) { + if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) { + throw new Error( + `${label} used ${new Set(pids).size} live watcher children; expected ${expected}` + ) + } +} + +function assertRegistryCapacity(reserveWatcherChild, availableReservations) { + const releases = [] + try { + for (let index = 0; index < availableReservations; index++) { + const release = reserveWatcherChild() + if (!release) { + throw new Error(`Watcher child registry rejected reservation ${index + 1}`) + } + releases.push(release) + } + const unexpectedReservation = reserveWatcherChild() + if (unexpectedReservation) { + unexpectedReservation() + throw new Error('Watcher child registry exceeded its global physical-process cap') + } + } finally { + releases.forEach((release) => release()) + } +} + +async function sampleResources(pids) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)) + return Promise.all(pids.map((pid) => sampleProcess(pid))) +} + +async function sampleProcess(pid) { + if (process.platform === 'win32') { + const command = + `$p=Get-Process -Id ${pid};` + + `[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress` + const first = JSON.parse( + (await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim() + ) + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)) + const second = JSON.parse( + (await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim() + ) + return { + pid, + rssKiB: second.rssKiB, + cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400) + } + } + const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], { + env: { ...process.env, LC_ALL: 'C' } + }) + const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number) + return { pid, rssKiB, cpuPercent } +} + +function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) { + for (const resource of resources) { + if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) { + throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`) + } + if (resource.rssKiB > MAX_CHILD_RSS_KIB) { + throw new Error( + `Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB` + ) + } + if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) { + throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`) + } + if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) { + throw new Error( + `Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%` + ) + } + } + const summary = summarizeResources(resources) + if (summary.totalRssKiB > maxTotalRssKiB) { + throw new Error( + `${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB` + ) + } + if (summary.sampledCpuPercent > maxTotalCpuPercent) { + throw new Error( + `${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%` + ) + } +} + +function summarizeResources(resources) { + return { + childCount: resources.length, + pids: resources.map(({ pid }) => pid), + totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0), + sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0) + } +} + +function isPidAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitFor(predicate, label) { + const deadline = Date.now() + WAIT_TIMEOUT_MS + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}`) + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)) + } +} + +await main() diff --git a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs b/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs deleted file mode 100644 index 512191599d0..00000000000 --- a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// Benchmark: main-process retained-Set growth of the serve-sim state watcher's -// external-helper dedupe across worktree switches. -// -// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl, -// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once. -// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never -// touched seenExternalKeys, so its keys accumulated for the renderer/main -// process lifetime — only cleared at app shutdown (stop()). This script -// simulates N bind→detect→forget cycles and reports retained Set size with the -// prune disabled vs enabled. -import { performance } from 'node:perf_hooks' - -const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10) -const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10) - -for (const [name, value] of [ - ['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES], - ['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a -// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix -// adds to forgetWorktree(). -function makeWatcher({ prune }) { - const seenExternalKeys = new Set() - return { - set: seenExternalKeys, - emitIfExternal(worktreeId, instanceKey) { - const dedupeKey = `${worktreeId}::${instanceKey}` - if (seenExternalKeys.has(dedupeKey)) { - return false - } - seenExternalKeys.add(dedupeKey) - return true - }, - forgetWorktree(worktreeId) { - if (!prune) { - return - } - const prefix = `${worktreeId}::` - for (const key of seenExternalKeys) { - if (key.startsWith(prefix)) { - seenExternalKeys.delete(key) - } - } - } - } -} - -function run({ prune }) { - const watcher = makeWatcher({ prune }) - const start = performance.now() - for (let cycle = 0; cycle < CYCLES; cycle++) { - const worktreeId = `worktree-${cycle}` - for (let h = 0; h < HELPERS_PER_WORKTREE; h++) { - // A realistic instance key: device udid :: pid :: wsUrl :: streamUrl. - const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg` - watcher.emitIfExternal(worktreeId, instanceKey) - } - watcher.forgetWorktree(worktreeId) - } - return { retained: watcher.set.size, elapsedMs: performance.now() - start } -} - -console.log( - `serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` + - `${HELPERS_PER_WORKTREE} helper(s)/worktree\n` -) - -const before = run({ prune: false }) -const after = run({ prune: true }) - -// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs). -const APPROX_BYTES_PER_KEY = 140 -const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024 - -console.log( - `before (no prune) retained=${before.retained} keys ` + - `(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms` -) -console.log( - `after (prune) retained=${after.retained} keys ` + - `(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms` -) -console.log( - `\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` + - `retained keys after ${CYCLES} worktree switches.` -) diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs new file mode 100644 index 00000000000..bfb10a50494 --- /dev/null +++ b/config/scripts/verify-skill-update-roundtrip.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' + +function option(name) { + return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) +} + +const cliVersion = option('cli') +const autocrlf = option('autocrlf') +const shape = option('shape') +// Why: PR branch names are untrusted workflow input. Keep them out of the +// generated shell command and pass them to Node through the environment. +const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE +const ref = option('ref') ?? process.env.SKILL_UPDATE_REF +if ( + !cliVersion || + (autocrlf !== 'true' && autocrlf !== 'false') || + (shape !== 'symlink' && shape !== 'copy') || + !source || + !ref || + !/^[^/\s]+\/[^/\s]+$/.test(source) +) { + throw new Error( + 'Usage: verify-skill-update-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy --source= --ref=' + ) +} + +const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) +const home = path.join(sandbox, 'home') +const stateHome = path.join(home, '.state') +const fakeBin = path.join(sandbox, 'bin') +const targetName = 'orca-cli' +const controlName = 'orchestration' +const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) +const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8')) +const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8')) + +function currentSkill(name) { + const skill = manifest.skills.find((entry) => entry.name === name) + if (!skill) { + throw new Error(`Current manifest is missing ${name}`) + } + return skill +} + +function historicalRelease(name) { + const current = currentSkill(name) + for (const release of releaseMapping.releases.toReversed()) { + const revision = release.skills[name] + if (typeof revision !== 'number' || revision >= current.releaseRevision) { + continue + } + const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision) + if (snapshot) { + return { tag: `v${release.appVersion}`, snapshot } + } + } + throw new Error(`No historical released snapshot is available for ${name}`) +} + +async function materializePackage(name, tag, destination) { + const prefix = `skills/${name}/` + const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`]) + .toString('utf8') + .split('\0') + .filter(Boolean) + if (entries.length === 0) { + throw new Error(`${tag} does not contain ${name}`) + } + for (const entry of entries) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match || match[2] !== 'blob') { + throw new Error(`Unsupported historical tree entry: ${entry}`) + } + const relativePath = match[4].slice(prefix.length) + const destinationPath = path.join(destination, ...relativePath.split('/')) + await mkdir(path.dirname(destinationPath), { recursive: true }) + await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]])) + if (process.platform !== 'win32' && match[1] === '100755') { + await chmod(destinationPath, 0o755) + } + } +} + +async function seedPlacement(name, tag) { + const canonical = path.join(home, '.agents', 'skills', name) + await materializePackage(name, tag, canonical) + const providerRoot = path.join(home, '.claude', 'skills') + const provider = path.join(providerRoot, name) + await mkdir(providerRoot, { recursive: true }) + await (shape === 'copy' + ? cp(canonical, provider, { recursive: true }) + : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) +} + +async function installFakeAgentCommands() { + await mkdir(fakeBin, { recursive: true }) + for (const name of ['codex', 'claude']) { + const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name) + await writeFile( + executable, + process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n' + ) + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + } +} + +async function packageDigestAt(pathValue) { + return packageDigest(await collectPackageFiles(pathValue)) +} + +async function assertCurrentCanonical(name) { + const expected = currentSkill(name).packageDigest + const canonical = path.join(home, '.agents', 'skills', name) + if ((await packageDigestAt(canonical)) !== expected) { + throw new Error(`${name} canonical placement did not update to the PR content`) + } +} + +function execSkills(args) { + const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' + const cliArgs = ['--yes', `skills@${cliVersion}`, ...args] + execFileSync( + executable, + process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, '.codex'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + XDG_STATE_HOME: stateHome, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + CI: '1' + }, + stdio: 'inherit' + } + ) +} + +try { + const targetHistorical = historicalRelease(targetName) + const controlHistorical = historicalRelease(controlName) + await installFakeAgentCommands() + await mkdir(path.join(home, '.codex'), { recursive: true }) + await mkdir(path.join(home, '.claude'), { recursive: true }) + await seedPlacement(targetName, targetHistorical.tag) + await seedPlacement(controlName, controlHistorical.tag) + const targetProvider = path.join(home, '.claude', 'skills', targetName) + const controlCanonical = path.join(home, '.agents', 'skills', controlName) + const controlProvider = path.join(home, '.claude', 'skills', controlName) + const targetProviderBefore = await packageDigestAt(await realpath(targetProvider)) + const controlBefore = await packageDigestAt(controlCanonical) + const controlProviderBefore = await packageDigestAt(await realpath(controlProvider)) + + const timestamp = new Date().toISOString() + const lock = { + version: 3, + skills: { + [targetName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${targetName}/SKILL.md`, + skillFolderHash: targetHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + }, + [controlName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${controlName}/SKILL.md`, + skillFolderHash: controlHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + } + } + } + const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') + await mkdir(path.dirname(lockPath), { recursive: true }) + await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) + + // Why: this is the exact user-visible rail. A bare update would include + // unrelated vendors, while this command must leave the control skill alone. + execSkills(['update', targetName, '--global']) + await assertCurrentCanonical(targetName) + const targetProviderAfter = await packageDigestAt(await realpath(targetProvider)) + const targetProviderStat = await lstat(targetProvider) + if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) { + throw new Error(`${targetName} provider alias was replaced with an independent copy`) + } + if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) { + throw new Error(`${targetName} provider alias did not converge with the canonical update`) + } + if ( + shape === 'copy' && + targetProviderAfter !== targetProviderBefore && + targetProviderAfter !== currentSkill(targetName).packageDigest + ) { + throw new Error('Independent provider copy changed to an unexpected package identity') + } + if (shape === 'copy') { + // Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs + // retain the copy. Both prove this input topology must remain ineligible. + const outcome = targetProviderStat.isSymbolicLink() + ? 'converged to an alias' + : targetProviderAfter === targetProviderBefore + ? 'remained a historical copy' + : 'converged as a copy' + console.log(`[skill-update-roundtrip] independent copy ${outcome}`) + } + if ((await packageDigestAt(controlCanonical)) !== controlBefore) { + throw new Error('Targeted update changed the non-targeted control skill') + } + if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) { + throw new Error('Targeted update changed the non-targeted control provider placement') + } + const controlProviderStat = await lstat(controlProvider) + if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) { + throw new Error('Targeted update changed the non-targeted control topology') + } +} finally { + await rm(sandbox, { recursive: true, force: true }) +} diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index ee1674f4124..417924fc997 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.6m + + downloads: 5.8m @@ -15,7 +15,7 @@ downloads downloads - 5.6m - 5.6m + 5.8m + 5.8m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index 39d06509590..e07ed23112f 100644 Binary files a/docs/assets/star-history.png and b/docs/assets/star-history.png differ diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg deleted file mode 100644 index a6392babbce..00000000000 Binary files a/docs/assets/wechat-qr-backup.jpg and /dev/null differ diff --git a/docs/assets/wechat-qr.jpg b/docs/assets/wechat-qr.jpg new file mode 100644 index 00000000000..0b4598e0f88 Binary files /dev/null and b/docs/assets/wechat-qr.jpg differ diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png deleted file mode 100644 index bf270d857e8..00000000000 Binary files a/docs/assets/wechat-qr.png and /dev/null differ diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 6e2161840d5..5e6638196da 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -17,7 +17,7 @@ El orquestador de IA para desarrolladores 100x. - Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar. + Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar. Descargar Orca diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 07f5054d4cd..e2516f34d12 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -17,7 +17,7 @@ 100x ビルダーのための AI オーケストレーター。 - Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。 + Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。 Orca をダウンロード diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 070b0acda1d..c03089e9091 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -17,7 +17,7 @@ 100x 빌더를 위한 AI 오케스트레이터. - Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다. + Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다. Orca 다운로드 @@ -164,7 +164,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다. - **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요. - **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요. -- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다. +- **그리고 훨씬 더 많은 기능** — 새로운 기능이 매일 출시되므로 이 목록은 늘 한 발 늦습니다. 진짜 기능은 [체인지로그](https://github.com/stablyai/orca/releases)에서 확인하세요. --- @@ -237,7 +237,7 @@ yay -S stably-orca-bin - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. -- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요. +- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요. --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c0351670195..0116866f048 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -17,7 +17,7 @@ 面向 100x 构建者的 AI 编排器。 - 并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。 + 并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。 下载 Orca @@ -235,10 +235,9 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 +- **微信:** 第一个微信群已满,现在可以加入第二个群。 - - + - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index c943bc9ce5f..b740982febb 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -35,7 +35,7 @@ authority. | Capability | Preferred behavior | Compatibility behavior | | ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` | +| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | | `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | | `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | diff --git a/docs/reference/ssh-gssapi-kerberos.md b/docs/reference/ssh-gssapi-kerberos.md new file mode 100644 index 00000000000..e7594151667 --- /dev/null +++ b/docs/reference/ssh-gssapi-kerberos.md @@ -0,0 +1,39 @@ +# Kerberos / GSSAPI SSH Authentication + +Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library +has no GSSAPI userauth support, and adding it would mean forking ssh2's +protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal / +Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead, +hosts that need Kerberos ride the existing **system OpenSSH transport** — the +same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts — +which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon), +and credential delegation to the platform's own `ssh` binary on macOS, Linux, +and Windows (Win32-OpenSSH uses SSPI). + +## Transport selection + +Two tiers, deliberately asymmetric because RHEL-family distros ship +`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes +`ssh -G` report GSSAPI enabled for *every* host: + +1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an + explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or + set on the target directly) tries the system-ssh probe first. If that fails + (e.g. no ticket), the connect falls through to the normal ssh2 key/agent + path where passphrase/password prompts remain available — OpenSSH semantics + allow other auth methods alongside GSSAPI. +2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the + `ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate` + in `ssh-connection-utils.ts`), the connection retries over system ssh + *before* prompting the user for credentials. Kerberos-only hosts on + distro-default configs connect this way; hosts where keys work never leave + the ssh2 path. + +Manual (non-ssh-config) targets flagged for GSSAPI get an explicit +`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets +inherit the option from their own `Host` block since the system binary re-reads +ssh_config. + +Both tiers work headless (`orca serve`): the system-ssh probe needs no +credential callbacks, and GSSAPI itself is non-interactive once a ticket +exists. diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md index 29161cd6fdf..0b11fd15557 100644 --- a/docs/reference/terminal-session-lifecycle.md +++ b/docs/reference/terminal-session-lifecycle.md @@ -46,6 +46,22 @@ persisted physical-to-owner pane-key alias for detach, plus a live Electron parked-close gate. Focused tests cover local, SSH, split, parked, shared, paired-host, ordinary runtime, pane-transfer, and late-spawn paths. +`orca terminal close --tab` extends that same ownership boundary to automation. +With an attached renderer, main sends a request/reply close, the renderer runs +the canonical retirement path, persists a fresh host-partitioned session, and +forces the main store to disk before acknowledging success. Without a renderer, +the runtime applies the equivalent immutable projection to the persisted +aggregate tab, removes every split binding and resume record, safely selects a +surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, +and flushes before returning. The ordinary `terminal close` pane/session +contract is unchanged. + +This durability boundary does not use replay fences or tombstones. Renderer +session writes are ordered through the same IPC sender, and a pending debounced +write rebuilds from the latest store state, so the forced fresh snapshot is not +followed by a captured pre-close topology patch. Headless closes update the +single main-owned session projection directly. + Origin-aware defensive expiry remains a follow-up. Immediate deletion on explicit close fixes the incident without applying a wall-clock policy that could invalidate intentional long-lived `worktree-sleep` checkpoints. @@ -74,8 +90,20 @@ could invalidate intentional long-lived `worktree-sleep` checkpoints. - Changing terminal output, snapshot, replay, query-response, or hidden-delivery behavior. -- Killing processes that deliberately daemonize away from the terminal process - group. This contract covers the PTY and its attached foreground process tree. +- Killing processes that deliberately daemonize away from a plain user + terminal's process group (nohup-style survivors remain user intent there). + For **agent sessions**, close/kill additionally terminates the snapshotted + descendant tree — including detached-pgid children the PTY's SIGHUP cannot + reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with + same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL). + Completed process tables are never reused as signal targets, and identity + checks use C-locale timestamps from the source scan. Later requests start a + fresh same-turn-coalesced successor inside their own deadline instead of + waiting behind older scans. A session is marked as terminating before capture + and keeps request ownership through natural exit, so reattach, duplicate kill, + and graceful-to-immediate upgrade cannot race the snapshot; descendant signals + still require the exact root session/handle to be live. Windows and SSH-hosted + PTYs keep the previous foreground-tree contract for now. - Changing agent-provider resume commands or permission flags. - Making a UI close wait for a remote process to exit before the tab disappears. - Replacing worktree sleep with tab close. Sleep remains resumable by design. diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx new file mode 100644 index 00000000000..790c755cd47 --- /dev/null +++ b/mobile/app/h/[hostId]/edit.tsx @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + View, + Text, + TextInput, + StyleSheet, + Pressable, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store' +import { + displayHostEndpoint, + endpointPort, + endpointScheme, + normalizeHostEndpoint +} from '../../../src/transport/host-endpoint' +import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context' +import type { HostProfile } from '../../../src/transport/types' + +export default function EditHostScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const { hostId } = useLocalSearchParams<{ hostId: string }>() + const primeHosts = usePrimeHosts() + const forceReconnectHost = useForceReconnect() + + const [host, setHost] = useState(null) + const [loadError, setLoadError] = useState(null) + const [name, setName] = useState('') + const [address, setAddress] = useState('') + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + // Why: setSaving is async, so a second trigger before the re-render could + // still read stale state and re-enter handleSave; the ref closes that race. + const savingRef = useRef(false) + + const load = useCallback(async () => { + if (!hostId) { + setLoadError('Missing host.') + return + } + try { + const hosts = await loadHosts() + const found = hosts.find((h) => h.id === hostId) ?? null + if (!found) { + setLoadError('This host was removed from this phone.') + setHost(null) + return + } + setHost(found) + setName(found.name) + setAddress(displayHostEndpoint(found.endpoint)) + setLoadError(null) + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Failed to load host.') + setHost(null) + } + }, [hostId]) + + useEffect(() => { + void load() + }, [load]) + + const fallbackPort = host ? endpointPort(host.endpoint) : undefined + const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws' + + const normalizedEndpoint = useMemo( + () => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }), + [address, fallbackPort, fallbackScheme] + ) + + const nameTrimmed = name.trim() + const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name + const endpointChanged = + host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint + const canSave = + host != null && + nameTrimmed.length > 0 && + normalizedEndpoint.ok && + (nameChanged || endpointChanged) && + !saving + + async function handleSave() { + if (!host || !hostId || savingRef.current) { + return + } + const nextName = name.trim() + if (!nextName) { + setSaveError('Enter a name.') + return + } + if (!normalizedEndpoint.ok) { + setSaveError(normalizedEndpoint.error) + return + } + + const willRename = nextName !== host.name + const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint + if (!willRename && !willUpdateEndpoint) { + router.back() + return + } + + savingRef.current = true + setSaving(true) + setSaveError(null) + try { + // Why: a single mutateStoredHosts pass so name + endpoint commit + // atomically — a mid-save failure can never persist one without the + // other, and a host removed mid-edit throws instead of no-oping. + await updateHostNameAndEndpoint(host.id, { + ...(willRename ? { name: nextName } : {}), + ...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {}) + }) + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save host.') + savingRef.current = false + setSaving(false) + return + } + + try { + // Why: the write already committed above; a re-prime failure here + // must not be reported as a save failure — the next loadHosts() call + // elsewhere in the app picks up the fresh state regardless. + const hosts = await loadHosts() + primeHosts(hosts) + } catch { + // best-effort re-prime; persisted data is unaffected + } + + savingRef.current = false + setSaving(false) + router.back() + + if (willUpdateEndpoint) { + // Why: reconnect is a follow-on side effect of a save that already + // committed — its failure or a hang must not be reported as a save + // failure or block navigating back. + void forceReconnectHost(host.id).catch(() => {}) + } + } + + return ( + + + router.back()} + accessibilityRole="button" + accessibilityLabel="Back" + > + + + Edit host + [ + styles.saveButton, + (!canSave || pressed) && styles.saveButtonDisabled + ]} + onPress={() => void handleSave()} + disabled={!canSave} + accessibilityRole="button" + accessibilityLabel="Save host" + > + {saving ? ( + + ) : ( + Save + )} + + + + {loadError ? ( + + {loadError} + router.back()}> + Go back + + + ) : !host ? ( + + + + ) : ( + + + + Change the display name or connection address. Address edits only switch where this + phone connects — they do not re-pair. Use this when the same desktop is reachable at a + different IP (for example home LAN vs Tailscale). + + + Name + { + setName(value) + setSaveError(null) + }} + placeholder="Host name" + placeholderTextColor={colors.textMuted} + autoCapitalize="words" + autoCorrect={false} + returnKeyType="next" + /> + + Address + { + setAddress(value) + setSaveError(null) + }} + placeholder="192.168.1.10:6768" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + keyboardType="url" + returnKeyType="done" + onSubmitEditing={() => { + if (canSave) { + void handleSave() + } + }} + /> + + Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port + (or 6768). + + + {normalizedEndpoint.ok ? ( + + Connects to {normalizedEndpoint.endpoint} + + ) : address.trim().length > 0 ? ( + {normalizedEndpoint.error} + ) : null} + + {saveError ? {saveError} : null} + + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + flex: { + flex: 1 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + gap: spacing.sm + }, + backButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center' + }, + heading: { + flex: 1, + color: colors.textPrimary, + fontSize: 20, + fontWeight: '700' + }, + saveButton: { + minWidth: 64, + height: 34, + paddingHorizontal: spacing.md, + borderRadius: radii.button, + backgroundColor: colors.surfaceBright, + alignItems: 'center', + justifyContent: 'center' + }, + saveButtonDisabled: { + opacity: 0.4 + }, + saveButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + form: { + paddingHorizontal: spacing.lg, + gap: spacing.sm + }, + help: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.sm + }, + label: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '500', + marginTop: spacing.sm, + textTransform: 'uppercase', + letterSpacing: 0.4 + }, + input: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.row, + color: colors.textPrimary, + fontSize: typography.bodySize, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? 12 : 10 + }, + hint: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 16 + }, + preview: { + marginTop: spacing.sm, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily + }, + previewError: { + marginTop: spacing.sm, + color: colors.statusRed, + fontSize: typography.bodySize + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + marginTop: spacing.md + }, + errorState: { + flex: 1, + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, + gap: spacing.md + }, + loadingState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + secondaryButton: { + alignSelf: 'flex-start', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + secondaryButtonText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '500' + } +}) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d4d1b35896f..0251da70490 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -26,10 +26,8 @@ import { Bot, ChevronDown, ChevronLeft, - ChevronRight, ChevronsRight, Copy, - Eraser, Folder, File, FileText, @@ -174,6 +172,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' @@ -198,6 +197,15 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' +import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' +import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' +import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' +import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' +import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' +import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' +import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab, focusMobileTerminal @@ -249,8 +257,6 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -type TerminalLiveAccessoryInput = ReturnType - const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function MarkdownReader({ @@ -1167,11 +1173,48 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) + const showNativeChatSendError = useCallback( + (message: string) => showToast(message, 1600), + [showToast] + ) + const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) + const { + ready: nativeChatInputLeaseReady, + readyRef: nativeChatInputLeaseReadyRef, + lockReason: nativeChatInputLockReason, + markReady: markNativeChatInputLeaseReady, + clear: clearNativeChatInputLease + } = useMobileNativeChatInputLease({ + activeHandle, + connected: connState === 'connected' + }) + const nativeChatController = useMobileNativeChatController({ + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError: showNativeChatSendError + }) + const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, enabled: canSend, onTranscript: (text) => { + // Why: dictation belongs to the visible composer. Native chat consumes it + // locally; terminal mode retains the live-input routing and flush contract. + if (showNativeChatRef.current) { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + showToast('Dictation inserted') + return + } // Live mode inserts the transcript straight into its originating PTY as // text (no Return — the user sends it themselves), matching live keystroke // semantics; buffered mode keeps appending to the command field. @@ -1290,22 +1333,27 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const unsubscribeTerminal = useCallback((handle: string) => { - terminalUnsubsRef.current.get(handle)?.() - terminalUnsubsRef.current.delete(handle) - subscribingHandlesRef.current.delete(handle) - terminalDiagnosticsRef.current.terminalUnsubscribed(handle) - subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. - layoutSeqRef.current.delete(handle) - }, []) + const unsubscribeTerminal = useCallback( + (handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) + clearNativeChatInputLease(handle) + }, + [clearNativeChatInputLease] + ) + const unsubscribeTerminalRef = useRef(unsubscribeTerminal) + unsubscribeTerminalRef.current = unsubscribeTerminal const clearTerminalCache = useCallback(() => { - for (const unsub of terminalUnsubsRef.current.values()) { - unsub() - } + terminalUnsubsRef.current.forEach((unsub) => unsub()) + clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() @@ -1318,7 +1366,7 @@ export default function SessionScreen() { for (const term of terminalRefs.current.values()) { term.clear() } - }, []) + }, [clearNativeChatInputLease]) // Why: measures the phone viewport once from the first available TerminalWebView. // The viewport dims are passed with every subscribe call so the server can @@ -1357,13 +1405,22 @@ export default function SessionScreen() { logSkippedGate('subscribe-in-flight') return } - if (!getTerminalRef(handle)) { - logSkippedGate('no-webview-ref') - return - } - if (!webReadyHandlesRef.current.has(handle)) { - logSkippedGate('webview-not-ready') - return + const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + // Why: a native-chat-covered terminal subscribes as the input-floor lease + // without a mounted xterm webview, so only gate on the webview when NOT covered. + if (!covered) { + if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') + return + } + if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') + return + } } subscribingHandlesRef.current.add(handle) @@ -1375,13 +1432,13 @@ export default function SessionScreen() { // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit // race and the measure→resize→resubscribe pipeline. - const unsub = client.subscribe( - 'terminal.subscribe', + const unsub = subscribeMobileTerminalSafely( + client, { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, viewport: viewportRef.current ?? undefined, - capabilities: { terminalBinaryStream: 1 } + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { @@ -1389,6 +1446,25 @@ export default function SessionScreen() { } const data = result as Record diagnostics.firstStreamEvent(handle, seq, data.type) + if (data.type === 'end' || data.type === 'error') { + unsubscribeTerminalRef.current(handle) + return + } + if (data.type === 'subscribed') { + markNativeChatInputLeaseReady(handle) + return + } + // Why: retain the subscription as the mobile input-floor lease, but + // do not mutate covered xterm state; return-to-terminal resubscribes. + if ( + nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + ) { + return + } // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1417,9 +1493,6 @@ export default function SessionScreen() { } else if (eventSeq != null && data.type === 'scrollback') { layoutSeqRef.current.set(handle, eventSeq) } - if (data.type === 'subscribed') { - return - } if (data.type === 'scrollback') { diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { @@ -1572,7 +1645,8 @@ export default function SessionScreen() { } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } - } + }, + () => unsubscribeTerminalRef.current(handle) ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1582,9 +1656,21 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) + const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle, + activeTabType: activeSessionTab?.type ?? null, + subscriptionsRef: terminalUnsubsRef, + subscribingRef: subscribingHandlesRef, + webReadyRef: webReadyHandlesRef, + initializedRef: initializedHandlesRef, + subscribe: subscribeToTerminal, + unsubscribe: unsubscribeTerminal + }) + // Why: toggles between phone and desktop mode via server RPC. The server // handles the actual resize and emits a 'resized' event on the existing // subscription stream — no client-side state tracking needed. @@ -3064,6 +3150,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + notifyTerminalWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -3105,7 +3192,7 @@ export default function SessionScreen() { })() } }, - [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3156,7 +3243,7 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { + async function handleAccessoryKey(input: ReturnType) { if (!client || !activeHandle || !canSend) { return } @@ -3643,7 +3730,7 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (input: TerminalLiveAccessoryInput) => { + (input: ReturnType) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { @@ -3815,19 +3902,14 @@ export default function SessionScreen() { showToast }) - const flushPendingLiveInputBeforeAttachmentSend = useCallback( - async (targetHandle: string): Promise => { - const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) - // Why: image picking/upload and IME flushing can outlive the original tab. - return ( - flushedPendingInput && - connStateRef.current === 'connected' && - targetHandle === activeHandleRef.current && - activeSessionTabTypeRef.current === 'terminal' - ) - }, - [flushPendingLiveInputBeforeExternalSend] - ) + const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast + }) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -4887,6 +4969,18 @@ export default function SessionScreen() { onOpenUrl={handleTerminalOpenUrl} /> ))} + void attachImage('library')} + isAttaching={isAttaching} + onMicPress={handleDictationToggle} + micActive={dictation.isRecording} + dictationMode={dictationMode} + onMicPressIn={handleDictationPressIn} + onMicPressOut={handleDictationPressOut} + inputLockReason={nativeChatInputLockReason} + keyboardInset={keyboardLift} + /> {toastMessage && ( {toastMessage} @@ -4896,8 +4990,9 @@ export default function SessionScreen() { )} {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. */} - {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && ( + trigger a server-side PTY viewport change. The dock hides in native + chat because that view supplies its own composer. */} + {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( { - const target = actionTarget - setActionTarget(null) - if (target) { - void toggleDisplayMode(target.handle) - } - } - } - ] - : []), - { - label: 'Rename', - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - setRenameTarget(target) - } - } - }, - { - label: 'Clear Terminal', - icon: Eraser, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleClearTerminal(target) - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleCloseTerminal(target) - } - } - } - ]} + actions={getMobileTerminalActionSheetActions({ + target: actionTarget, + tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), + chatTabIds, + nativeChatTranscriptIsLocalReadable, + onDismiss: () => setActionTarget(null), + onToggleChat: toggleTabChatView, + isPhoneMode, + onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), + onRename: setRenameTarget, + onClear: (target) => void handleClearTerminal(target), + onClose: (target) => void handleCloseTerminal(target) + })} onClose={() => setActionTarget(null)} /> setFileActionTarget(null)} /> - { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.back') - } - } - } - ] - : []), - ...(browserActionTarget?.canGoForward - ? [ - { - label: 'Forward', - icon: ChevronRight, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.forward') - } - } - } - ] - : []), - { - label: 'Reload', - icon: RefreshCw, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.reload') - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } - ]} + setBrowserActionTarget(null)} + onNavigate={handleBrowserNavigationCommand} + onCloseTab={handleCloseSessionTab} /> + diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e26893afd84..b33aac12610 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -24,7 +24,7 @@ import { UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHosts, renameHost } from '../src/transport/host-store' +import { loadHosts } from '../src/transport/host-store' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' @@ -42,7 +42,6 @@ import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileHostCard } from '../src/components/MobileHostCard' import { TaskProviderLogo } from '../src/components/TaskProviderLogo' -import { TextInputModal } from '../src/components/TextInputModal' import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal' import { ConfirmModal } from '../src/components/ConfirmModal' import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache' @@ -306,7 +305,6 @@ export default function HomeScreen() { const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [renameTarget, setRenameTarget] = useState(null) const [confirmRemove, setConfirmRemove] = useState(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) @@ -708,19 +706,6 @@ export default function HomeScreen() { ) - async function handleRename(newName: string) { - if (!renameTarget) { - return - } - try { - await renameHost(renameTarget.id, newName) - setRenameTarget(null) - setHosts(await loadHosts()) - } catch { - setRenameTarget(null) - } - } - async function handleRemove() { if (!confirmRemove) { return @@ -1068,11 +1053,12 @@ export default function HomeScreen() { }) } items.push({ - label: 'Rename', + label: 'Edit host', icon: Edit3, closeBeforePress: true, onPress: () => { - setRenameTarget(host) + setActionTarget(null) + router.push(`/h/${host.id}/edit`) } }) items.push({ @@ -1088,16 +1074,6 @@ export default function HomeScreen() { onClose={() => setActionTarget(null)} /> - void handleRename(name)} - onCancel={() => setRenameTarget(null)} - /> - () const [status, setStatus] = useState('awaiting-confirm') @@ -104,6 +106,13 @@ export default function PairConfirmScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + // Why: re-pairing the same desktop now reuses its existing host id + // (STA-1840 dedup), so a client cached under that id from an earlier + // pairing would keep the stale endpoint/relay. Close it so the + // destination screen opens a fresh client with the newly-paired + // profile — the removeHost() path already refreshes on re-pair, and a + // brand-new host has no cached entry so this is a no-op. + closeHost(hostId) const showNotificationOptIn = await shouldPresentNotificationOptIn() if (!mountedRef.current) { return diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index aab9d86bbf5..6b980ee0be6 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -18,6 +18,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' +import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -43,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) { export default function PairScanScreen() { const router = useRouter() + const closeHost = useCloseHost() const insets = useSafeAreaInsets() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') @@ -148,6 +150,13 @@ export default function PairScanScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + // Why: re-pairing the same desktop now reuses its existing host id + // (STA-1840 dedup), so a client cached under that id from an earlier + // pairing would keep the stale endpoint/relay. Close it so the + // destination screen opens a fresh client with the newly-paired + // profile — the removeHost() path already refreshes on re-pair, and a + // brand-new host has no cached entry so this is a no-op. + closeHost(hostId) const showNotificationOptIn = await shouldPresentNotificationOptIn() if (!mountedRef.current) { return diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 3200dcc5d71..8e968f604ac 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,12 +1,24 @@ import { Fragment, memo, useMemo, type ReactNode } from 'react' -import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { Linking, Pressable, ScrollView, Text, View } from 'react-native' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' +import { styles } from './mobile-markdown-styles' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' import { parseMobileMarkdown } from './mobile-markdown-parser' type Props = { content?: string fallback?: string + /** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1; + * the chat view passes >1 so agent prose reads larger than the compact base. */ + textScale?: number + /** When provided, detected file-path tokens render as tappable and invoke this + * with the worktree-relative path. Omitted on screens with no file viewer, where + * paths render as plain text (no behavior change). */ + onOpenFile?: (relativePath: string) => void } const MAX_TABLE_ROWS = 40 @@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void { } } -function renderInline(text: string): ReactNode[] { +// Render a plain (non-token) text run, splitting out tappable file paths when +// onOpenFile is provided. Without it, paths stay plain text. +function renderTextRun( + text: string, + keyPrefix: string, + onOpenFile?: (relativePath: string) => void +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => { + if (segment.type === 'file') { + return ( + onOpenFile(segment.path)} + > + {segment.value} + + ) + } + return {segment.value} + }) +} + +function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g @@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] { while ((match = pattern.exec(text))) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)) + parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) } const token = match[0] const key = `${match.index}:${token}` @@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] { ) } else if (token.startsWith('`')) { - parts.push( - - {token.slice(1, -1)} - - ) + const code = token.slice(1, -1) + if (onOpenFile && isFilePathCodeSpan(code)) { + parts.push( + onOpenFile(normalizeFilePath(code.trim()))} + > + {code} + + ) + } else { + parts.push( + + {code} + + ) + } } else if (token.startsWith('~~')) { parts.push( @@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] { } if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)) + parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) } return parts } -function MobileMarkdownInner({ content, fallback = '' }: Props) { +function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) { const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const proseScale = scaled(13) + const listScale = scaled(14) if (!text) { return fallback ? {fallback} : null } @@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { key={index} style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]} > - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } if (block.type === 'quote') { return ( - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } @@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((header, cellIndex) => ( - {renderInline(header)} + {renderInline(header, onOpenFile)} ))} @@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((_, cellIndex) => ( - {renderInline(row[cellIndex] ?? '')} + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { ? '[x]' : '[ ]'} - {renderInline(item.text)} + + {renderInline(item.text, onOpenFile)} + ))} @@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} - {renderInline(line)} + {renderInline(line, onOpenFile)} ))} @@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { } export const MobileMarkdown = memo(MobileMarkdownInner) - -const styles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - paragraph: { - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - heading: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.textPrimary - }, - headingLarge: { - fontSize: 15, - lineHeight: 21 - }, - bold: { - fontWeight: '700', - color: colors.textPrimary - }, - italic: { - fontStyle: 'italic' - }, - strike: { - textDecorationLine: 'line-through' - }, - link: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - inlineCode: { - fontFamily: typography.monoFamily, - fontSize: 12, - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderRadius: radii.row, - paddingHorizontal: 4 - }, - quote: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm - }, - quoteText: { - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary - }, - codeBlock: { - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - padding: spacing.sm - }, - codeLanguage: { - fontSize: 10, - color: colors.textMuted, - marginBottom: spacing.xs, - textTransform: 'uppercase' - }, - codeText: { - fontFamily: typography.monoFamily, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - imageFrame: { - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgRaised, - overflow: 'hidden', - padding: spacing.sm - }, - imageCaption: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 11, - color: colors.textSecondary - }, - table: { - borderTopWidth: 1, - borderLeftWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - overflow: 'hidden', - backgroundColor: colors.bgPanel - }, - tableRow: { - flexDirection: 'row' - }, - tableCell: { - minWidth: 112, - maxWidth: 220, - borderRightWidth: 1, - borderBottomWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - tableHeader: { - fontWeight: '700', - backgroundColor: colors.bgRaised - }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, - list: { - gap: spacing.xs - }, - listItem: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm - }, - listMarker: { - width: 22, - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary, - fontFamily: typography.monoFamily - }, - listText: { - flex: 1, - minWidth: 0, - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - rule: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle - } -}) diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts new file mode 100644 index 00000000000..f75da01efdc --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' + +describe('detectFilePathSegments', () => { + it('returns a single text segment when there is no path', () => { + expect(detectFilePathSegments('just some prose here')).toEqual([ + { type: 'text', value: 'just some prose here' } + ]) + }) + + it('detects a relative source path with surrounding prose', () => { + const segments = detectFilePathSegments('Edit src/app/Main.tsx now') + expect(segments).toEqual([ + { type: 'text', value: 'Edit ' }, + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }, + { type: 'text', value: ' now' } + ]) + }) + + it('strips a leading ./ in the path but keeps the displayed value', () => { + const segments = detectFilePathSegments('see ./lib/x.ts') + expect(segments).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: './lib/x.ts', path: 'lib/x.ts' } + ]) + }) + + it('keeps ../ parent-relative paths intact', () => { + const segments = detectFilePathSegments('../shared/util.ts') + expect(segments).toEqual([ + { type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' } + ]) + }) + + it('detects multiple paths in one run', () => { + const segments = detectFilePathSegments('a/b.ts and c/d/e.json') + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'a/b.ts', path: 'a/b.ts' }, + { type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' } + ]) + }) + + it('detects Windows relative, drive, and UNC paths', () => { + const segments = detectFilePathSegments( + String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md` + ) + + expect(segments.filter((segment) => segment.type === 'file')).toEqual([ + { type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` }, + { + type: 'file', + value: String.raw`C:\repo\config.json`, + path: String.raw`C:\repo\config.json` + }, + { + type: 'file', + value: String.raw`\\server\share\docs\readme.md`, + path: String.raw`\\server\share\docs\readme.md` + } + ]) + }) + + it('does not match bare filenames without a slash', () => { + expect(detectFilePathSegments('open Main.tsx please')).toEqual([ + { type: 'text', value: 'open Main.tsx please' } + ]) + }) + + it('does not match URLs', () => { + expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ + { type: 'text', value: 'https://example.com/path/file.ts' } + ]) + }) + + it('does not match version numbers', () => { + expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([ + { type: 'text', value: 'upgraded to 1.2.3 today' } + ]) + }) + + it('does not match unknown extensions', () => { + expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([ + { type: 'text', value: 'path/to/thing.whatever' } + ]) + }) + + it('detects scoped-package file paths with a segment-leading @', () => { + expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([ + { type: 'text', value: 'open ' }, + { + type: 'file', + value: '@types/react/index.d.ts', + path: '@types/react/index.d.ts' + }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file') + ).toEqual([ + { + type: 'file', + value: 'node_modules/@scope/pkg/file.ts', + path: 'node_modules/@scope/pkg/file.ts' + } + ]) + }) + + it('does not match emails or git URLs with a mid-token @', () => { + expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([ + { type: 'text', value: 'clone git@github.com:user/repo.git' } + ]) + expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([ + { type: 'text', value: 'open user@host.com/path/file.txt' } + ]) + }) + + it('returns a single text segment when the run has no dot', () => { + const text = 'a/'.repeat(8192) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('skips detection for runs over the length cap even with dots', () => { + // 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that + // bounds CANDIDATE_PATTERN's worst-case backtracking. + const text = 'a.b/'.repeat(2000) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('still detects a path in a long-but-under-cap run', () => { + const prefix = 'context '.repeat(200) + const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`) + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } + ]) + }) +}) + +describe('isFilePathCodeSpan', () => { + it('accepts a slashed path code span', () => { + expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true) + }) + + it('accepts Windows paths in code spans', () => { + expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true) + }) + + it('accepts a bare filename code span', () => { + expect(isFilePathCodeSpan('package.json')).toBe(true) + }) + + it('rejects multi-word code spans', () => { + expect(isFilePathCodeSpan('npm run build')).toBe(false) + }) + + it('rejects non-file code spans', () => { + expect(isFilePathCodeSpan('someVariable')).toBe(false) + }) + + it('rejects urls in code spans', () => { + expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false) + }) + + it('accepts scoped-package paths with a segment-leading @', () => { + expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true) + expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) + }) + + it('rejects emails and git URLs with a mid-token @', () => { + expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) + expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) + }) +}) + +describe('normalizeFilePath', () => { + it('strips a leading ./', () => { + expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') + expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`) + }) + + it('leaves other paths unchanged', () => { + expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts') + expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts') + }) +}) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts new file mode 100644 index 00000000000..a33c0dceae4 --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -0,0 +1,212 @@ +// Conservative detection of file-path-like tokens inside an inline markdown text +// run, so the chat view can render them as tappable (opening the mobile file +// viewer). We deliberately favor precision over recall: a missed path is a minor +// annoyance, but a false positive on prose or a version number is a broken tap. + +export type FilePathSegment = + | { type: 'text'; value: string } + | { type: 'file'; value: string; path: string } + +// Common source/code/config extensions we treat as openable file paths. Kept +// explicit (rather than "any extension") so prose like "etc." or "e.g." and +// domain-ish tokens like "example.com" don't get matched. +const FILE_EXTENSIONS = [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'md', + 'mdx', + 'markdown', + 'txt', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'kt', + 'kts', + 'swift', + 'c', + 'h', + 'cc', + 'cpp', + 'hpp', + 'cs', + 'php', + 'sh', + 'bash', + 'zsh', + 'fish', + 'yml', + 'yaml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'lock', + 'sql', + 'graphql', + 'gql', + 'proto', + 'xml', + 'svg', + 'vue', + 'svelte', + 'astro', + 'dart', + 'ex', + 'exs', + 'erl', + 'lua', + 'pl', + 'r', + 'scala', + 'clj', + 'gradle', + 'dockerfile', + 'gitignore', + 'npmrc' +] as const + +const EXTENSION_SET = new Set(FILE_EXTENSIONS) + +// Accept the host's native separator because transcript paths originate on the +// connected runtime, which may be Windows even when the phone is not. +const CANDIDATE_PATTERN = + /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + +// A path candidate in chat prose is short; a much longer run can't hold one worth +// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we +// skip detection entirely above this cap. +const MAX_DETECTION_LENGTH = 2000 + +// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such +// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and +// stays eligible. +function hasMidTokenAt(candidate: string): boolean { + return /[^\\/]@/.test(candidate) +} + +function isOpenablePath(candidate: string): boolean { + // Reject anything URL-ish or scheme-bearing — those are handled as web links. + if (candidate.includes('://') || hasMidTokenAt(candidate)) { + return false + } + // Must contain a separator (a bare "file.ts" is too ambiguous in prose). + if (!/[\\/]/.test(candidate)) { + return false + } + const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')) + const lastSegment = candidate.slice(lastSeparator + 1) + const dot = lastSegment.lastIndexOf('.') + // A leading-dot dotfile in the final segment (e.g. ".env") has no extension to + // anchor on; require a real name.ext shape. + if (dot <= 0) { + return false + } + const ext = lastSegment.slice(dot + 1).toLowerCase() + if (!EXTENSION_SET.has(ext)) { + return false + } + // Guard against version-number-ish tails ("1.2.3" style) where the "extension" + // is purely numeric — those aren't files. + if (/^\d+$/.test(ext)) { + return false + } + return true +} + +// Strip the leading ./ marker so callers receive a clean worktree-relative path, +// while keeping ../ (which is meaningful) intact. +export function normalizeFilePath(path: string): string { + return path.replace(/^\.[\\/]/, '') +} + +/** + * Given a plain inline text run, return ordered segments marking which substrings + * are openable file paths. Non-path text is preserved verbatim so the renderer can + * reassemble the run exactly. Returns a single text segment when nothing matches. + */ +export function detectFilePathSegments(text: string): FilePathSegment[] { + // Every real match ends in a name.ext, so a dot is mandatory; skip the regex when + // there is none, or when the run is too long to hold a chat path but long enough + // to drive CANDIDATE_PATTERN into super-linear backtracking. + if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) { + return [{ type: 'text', value: text }] + } + const segments: FilePathSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + CANDIDATE_PATTERN.lastIndex = 0 + + while ((match = CANDIDATE_PATTERN.exec(text))) { + const candidate = match[0] + // Skip candidates that are part of a URL (preceded by a scheme colon or an + // alphanumeric/host char that would make this a domain tail, not a path). + const prev = match.index > 0 ? text[match.index - 1]! : '' + if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + continue + } + if (!isOpenablePath(candidate)) { + continue + } + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) }) + lastIndex = match.index + candidate.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + // Collapse to a single text segment for the common no-match case. + if (segments.length === 0) { + return [{ type: 'text', value: text }] + } + return segments +} + +/** + * True when an inline-code span's entire content is a single openable file path + * (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a + * bare `file.ts` here even without a slash. + */ +export function isFilePathCodeSpan(code: string): boolean { + const trimmed = code.trim() + if (!trimmed || /\s/.test(trimmed)) { + return false + } + if (trimmed.includes('://') || hasMidTokenAt(trimmed)) { + return false + } + if (isOpenablePath(trimmed)) { + return true + } + // Separator-less code span: accept a clean name.ext with a known extension. + if (/[\\/]/.test(trimmed)) { + return false + } + const dot = trimmed.lastIndexOf('.') + if (dot <= 0) { + return false + } + const name = trimmed.slice(0, dot) + const ext = trimmed.slice(dot + 1).toLowerCase() + if (/[^\w.@+-]/.test(name)) { + return false + } + return EXTENSION_SET.has(ext) +} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts new file mode 100644 index 00000000000..414a8daf57b --- /dev/null +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + inlineCodeLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts new file mode 100644 index 00000000000..bb5ddf4faa6 --- /dev/null +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -0,0 +1,106 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import EditHostScreen from '../app/h/[hostId]/edit' + +const dependencies = vi.hoisted(() => ({ + back: vi.fn(), + forceReconnectHost: vi.fn(), + loadHosts: vi.fn(), + primeHosts: vi.fn(), + updateHostNameAndEndpoint: vi.fn() +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + KeyboardAvoidingView: 'KeyboardAvoidingView', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }) +})) + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ hostId: 'host-1' }), + useRouter: () => ({ back: dependencies.back }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronLeft: 'ChevronLeft' +})) + +vi.mock('./transport/host-store', () => ({ + loadHosts: dependencies.loadHosts, + updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint +})) + +vi.mock('./transport/client-context', () => ({ + useForceReconnect: () => dependencies.forceReconnectHost, + usePrimeHosts: () => dependencies.primeHosts +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +async function renderEditHostRoute(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('Edit host route did not render') + } + return renderer +} + +describe('edit host route accessibility', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.loadHosts.mockReset().mockResolvedValue([ + { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token', + publicKeyB64: 'public-key', + lastConnected: 1 + } + ]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes stable accessible names for both editable fields', async () => { + const renderer = await renderEditHostRoute() + + const inputs = renderer.root.findAllByType('TextInput') + expect(inputs).toHaveLength(2) + expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address']) + + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts new file mode 100644 index 00000000000..1e1c252403c --- /dev/null +++ b/mobile/src/host-edit-save-flow.test.ts @@ -0,0 +1,325 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import EditHostScreen from '../app/h/[hostId]/edit' + +const dependencies = vi.hoisted(() => ({ + back: vi.fn(), + forceReconnectHost: vi.fn(), + loadHosts: vi.fn(), + primeHosts: vi.fn(), + updateHostNameAndEndpoint: vi.fn(), + hostId: 'host-1' as string | undefined +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + KeyboardAvoidingView: 'KeyboardAvoidingView', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }) +})) + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ hostId: dependencies.hostId }), + useRouter: () => ({ back: dependencies.back }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronLeft: 'ChevronLeft' +})) + +vi.mock('./transport/host-store', () => ({ + loadHosts: dependencies.loadHosts, + updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint +})) + +vi.mock('./transport/client-context', () => ({ + useForceReconnect: () => dependencies.forceReconnectHost, + usePrimeHosts: () => dependencies.primeHosts +})) + +const HOST_FIXTURE = { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token', + publicKeyB64: 'public-key', + lastConnected: 1 +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +async function renderEditHostRoute(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('Edit host route did not render') + } + return renderer +} + +function setFieldValue( + renderer: ReactTestRenderer, + accessibilityLabel: 'Name' | 'Address', + value: string +): void { + const input = renderer.root + .findAllByType('TextInput') + .find((node) => node.props.accessibilityLabel === accessibilityLabel) + if (!input) { + throw new Error(`${accessibilityLabel} input not found`) + } + act(() => { + input.props.onChangeText(value) + }) +} + +function findSaveButton(renderer: ReactTestRenderer) { + const button = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === 'Save host') + if (!button) { + throw new Error('Save button not found') + } + return button +} + +async function pressSave(renderer: ReactTestRenderer): Promise { + const button = findSaveButton(renderer) + await act(async () => { + button.props.onPress() + await Promise.resolve() + await Promise.resolve() + }) +} + +function findText(renderer: ReactTestRenderer, match: string): boolean { + return renderer.root.findAllByType('Text').some((node) => { + const children = node.props.children + if (typeof children === 'string') { + return children.includes(match) + } + if (Array.isArray(children)) { + return children.join('').includes(match) + } + return false + }) +} + +describe('edit host handleSave', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.hostId = 'host-1' + dependencies.back.mockReset() + dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) + dependencies.primeHosts.mockReset() + dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('rename-only save updates only the name and does not reconnect', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + name: 'Home Desk' + }) + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('endpoint-only save updates only the endpoint and reconnects', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + endpoint: 'ws://192.168.1.20:6768' + }) + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('saves name and endpoint together in one call, then reconnects', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1) + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + name: 'Home Desk', + endpoint: 'ws://192.168.1.20:6768' + }) + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('navigates back without saving or reconnecting when nothing changed', async () => { + const renderer = await renderEditHostRoute() + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled() + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('shows the error and does not navigate back or reconnect when the save rejects', async () => { + dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(findText(renderer, 'Host not found')).toBe(true) + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + }) + + it('still navigates back when the post-save re-prime fails', async () => { + dependencies.loadHosts + .mockResolvedValueOnce([HOST_FIXTURE]) + .mockRejectedValueOnce(new Error('boom')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(dependencies.primeHosts).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('still navigates back and shows no error when the post-save reconnect rejects', async () => { + dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + expect(findText(renderer, 'connect failed')).toBe(false) + + act(() => renderer.unmount()) + }) + + it('ignores a second Save trigger while a save is already in flight', async () => { + let resolveSave: () => void = () => {} + dependencies.updateHostNameAndEndpoint.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve + }) + ) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + const button = findSaveButton(renderer) + + await act(async () => { + button.props.onPress() + button.props.onPress() + await Promise.resolve() + }) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveSave() + await Promise.resolve() + await Promise.resolve() + }) + + act(() => renderer.unmount()) + }) +}) + +describe('edit host load() error states', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.hostId = 'host-1' + dependencies.back.mockReset() + dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) + dependencies.primeHosts.mockReset() + dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows "Missing host." when hostId is absent', async () => { + dependencies.hostId = undefined + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'Missing host.')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) + + it('shows a not-saved message when the host is not in the loaded list', async () => { + dependencies.loadHosts.mockReset().mockResolvedValue([]) + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'This host was removed from this phone.')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) + + it('surfaces the error message when loadHosts rejects', async () => { + dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable')) + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'storage unreadable')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx new file mode 100644 index 00000000000..cb766b0be51 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Animated, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** Animated three-dot "agent is working" row, shown while the active agent is + * still producing a reply. Pure presentation — visibility is the caller's call. */ +export function MobileAgentWorkingIndicator(): React.JSX.Element { + const dots = [ + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current + ] + + useEffect(() => { + const animations = dots.map((dot, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true }) + ]) + ) + ) + animations.forEach((a) => a.start()) + return () => animations.forEach((a) => a.stop()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + Agent is working + + {dots.map((dot, i) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontStyle: 'italic' + }, + dots: { + flexDirection: 'row', + gap: 4 + }, + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx new file mode 100644 index 00000000000..3baa6da8c3a --- /dev/null +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -0,0 +1,79 @@ +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' +import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' + +type BrowserTab = Extract +export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload' + +/** Keeps browser-tab navigation actions out of the session route while preserving + * the target captured at the moment each drawer action is pressed. */ +export function MobileBrowserTabActionSheet(props: { + target: BrowserTab | null + onClose: () => void + onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void + onCloseTab: (target: BrowserTab) => void +}): React.JSX.Element { + const { target, onClose, onNavigate, onCloseTab } = props + return ( + { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.back') + } + } + } + ] + : []), + ...(target?.canGoForward + ? [ + { + label: 'Forward', + icon: ChevronRight, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.forward') + } + } + } + ] + : []), + { + label: 'Reload', + icon: RefreshCw, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.reload') + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const current = target + onClose() + if (current) { + onCloseTab(current) + } + } + } + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx new file mode 100644 index 00000000000..b485818970c --- /dev/null +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -0,0 +1,378 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' + +type Props = { + prompt: AskPrompt + /** Deliver the chosen answer (per-question option indices + free text) — + * index-based so Claude's arrow-navigate selector can be driven by the + * option's stable number instead of pasted label text (STA-1860). */ + onAnswer: (selections: AskAnswerSelection[]) => Promise + onCancel?: () => Promise +} + +// Sentinel index for the free-text "Other…" row (never a real option index). +const OTHER = -1 + +/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one + * question per step with tabs across the top, a Next button that advances (Send + * on the last step), and a Cancel that dismisses the prompt. Neutral styling + * with a subtle green accent on the active choice to match the rest of the app. */ +export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { + const [index, setIndex] = useState(0) + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + const toggle = (qi: number, optIndex: number, multi: boolean): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[qi] ?? [] + if (multi) { + next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex] + } else { + next[qi] = cur.includes(optIndex) ? [] : [optIndex] + } + return next + }) + } + + const setOther = (qi: number, value: string): void => { + setOtherText((prev) => { + const next = [...prev] + next[qi] = value + return next + }) + } + + const selectionFor = (qi: number): AskAnswerSelection => { + const picked = (selections[qi] ?? []).filter((i) => i !== OTHER) + const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' + return other ? { indices: picked, other } : { indices: picked } + } + + const isAnswered = (qi: number): boolean => { + const sel = selectionFor(qi) + return sel.indices.length > 0 || (sel.other ?? '').length > 0 + } + + const total = prompt.questions.length + const isLast = index === total - 1 + const currentAnswered = useMemo( + () => isAnswered(index), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selections, otherText, index] + ) + const allAnswered = useMemo( + () => prompt.questions.every((_, i) => isAnswered(i)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [otherText, prompt.questions, selections] + ) + const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + + const submit = async (): Promise => { + if (!allAnswered || submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + try { + await onAnswer(prompt.questions.map((_, i) => selectionFor(i))) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const advance = async (): Promise => { + if (isLast) { + await submit() + } else { + setIndex((i) => Math.min(i + 1, total - 1)) + } + } + + const q = prompt.questions[index]! + const otherSelected = (selections[index] ?? []).includes(OTHER) + + return ( + + {total > 1 ? ( + + {prompt.questions.map((qq, i) => ( + setIndex(i)} + > + + {qq.header || `Step ${i + 1}`} + + {isAnswered(i) ? ( + + ) : null} + + ))} + + ) : null} + + + {q.question} + {q.options.map((opt, optIndex) => ( + toggle(index, optIndex, q.multiSelect)} + /> + ))} + toggle(index, OTHER, q.multiSelect)} + /> + {otherSelected ? ( + setOther(index, v)} + placeholder="Type your answer" + placeholderTextColor={colors.textMuted} + multiline + autoFocus + /> + ) : null} + + + + { + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + }} + disabled={submitting} + hitSlop={8} + > + Cancel + + {total > 1 ? ( + + {index + 1}/{total} + + ) : null} + + + {isLast ? 'Send answer' : 'Next'} + + + + + ) +} + +function OptionRow({ + label, + description, + selected, + multi, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + maxHeight: 380, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + tabs: { + flexGrow: 0, + paddingTop: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + tabsContent: { + paddingHorizontal: spacing.sm, + gap: spacing.xs, + alignItems: 'center' + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 36, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.statusGreen + }, + tabText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + tabTextActive: { + color: colors.textPrimary + }, + scroll: { + paddingHorizontal: spacing.md + }, + questionText: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + marginVertical: spacing.sm + }, + option: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + input: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + color: colors.textPrimary, + fontSize: typography.bodySize, + padding: spacing.sm, + minHeight: 44, + marginBottom: spacing.xs + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.md, + gap: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + cancel: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + progress: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + next: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radii.button, + backgroundColor: colors.textPrimary + }, + nextDisabled: { + backgroundColor: colors.bgRaised + }, + nextText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + nextTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts new file mode 100644 index 00000000000..ef2c1012603 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -0,0 +1,206 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square' +})) + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatComposer', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render( + onSend: (text: string) => Promise, + onChangeText: () => void, + isAttaching = false + ) { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) + } finally { + restore() + } + } + + function sendButton(): { props: { onPress: () => Promise } } { + if (!renderer) { + throw new Error('Composer was not rendered') + } + return renderer.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) as { props: { onPress: () => Promise } } + } + + it('reports an accepted send without owning route-scoped draft cleanup', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('keeps the draft when the send is rejected', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(false) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('disables send while an attachment path is still being injected', async () => { + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, vi.fn(), true) + + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + + it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true) + }) + ) + }) + } finally { + restore() + } + const input = () => + renderer!.root.find((node) => node.type === 'TextInput') as { + props: { + selection?: { start: number; end: number } + onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void + } + } + // Uncontrolled selection until a suggestion is applied. + expect(input().props.selection).toBeUndefined() + // Place the caret at the end so the slash trigger is active and suggestions render. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) + ) + const firstSuggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => firstSuggestion.props.onPress()) + // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. + expect(input().props.selection).toEqual({ start: 7, end: 7 }) + // The next native selection event releases control so manual placement still works. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } }) + ) + expect(input().props.selection).toBeUndefined() + }) + + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { + const onMicPress = vi.fn() + const onMicPressIn = vi.fn() + const onMicPressOut = vi.fn() + const mic = () => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' + ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) + + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() + } finally { + restore() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx new file mode 100644 index 00000000000..ba02fe3d44b --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -0,0 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +// Common agent slash commands offered as autocomplete; sending them is just text +// to the agent's terminal, so the set is intentionally provider-agnostic. +const SLASH_COMMANDS = [ + '/clear', + '/compact', + '/review', + '/model', + '/help', + '/init', + '/cost', + '/diff' +] + +const NO_FILE_PATHS: string[] = [] + +type Props = { + /** Controlled composer text — owned by the parent so dictation can write to it. */ + value: string + onChangeText: (text: string) => void + onSend: (text: string) => Promise + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + /** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */ + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + disabled?: boolean + placeholder?: string + filePaths?: string[] + onNeedFiles?: (query: string) => void +} + +export function MobileNativeChatComposer({ + value, + onChangeText, + onSend, + onAttachImage, + isAttaching = false, + onMicPress, + micActive = false, + dictationMode = 'toggle', + onMicPressIn, + onMicPressOut, + disabled = false, + placeholder = 'Message, @files, /commands', + filePaths = NO_FILE_PATHS, + onNeedFiles +}: Props): React.JSX.Element { + const [cursor, setCursor] = useState(0) + // Transiently drives the native caret after a mid-text autocomplete insert, + // then released on the next selection change so manual caret placement still + // works (a permanently controlled `selection` breaks it in React Native). + const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( + null + ) + const sendingRef = useRef(false) + const [sending, setSending] = useState(false) + const trimmed = value.trim() + const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + const suggestions = useMemo(() => { + if (!trigger) { + return [] + } + if (trigger.kind === 'slash') { + return rankSuggestions(SLASH_COMMANDS, trigger.query) + } + return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) + }, [trigger, filePaths]) + + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + + const handleChange = (next: string): void => { + onChangeText(next) + } + + const pickSuggestion = (suggestion: string): void => { + if (!trigger) { + return + } + const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + onChangeText(nextText) + setCursor(nextCursor) + setPendingSelection({ start: nextCursor, end: nextCursor }) + } + + const handleSend = async (): Promise => { + if (!canSend || sendingRef.current) { + return + } + sendingRef.current = true + setSending(true) + try { + const accepted = await onSend(trimmed) + if (accepted) { + setCursor(0) + } + } finally { + sendingRef.current = false + setSending(false) + } + } + + return ( + + {suggestions.length > 0 ? ( + + + {suggestions.map((s) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => pickSuggestion(s)} + > + + {s} + + + ))} + + + ) : null} + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + editable={!disabled} + textAlignVertical="top" + /> + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 180 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + bar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + input: { + flex: 1, + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + iconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center' + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + // White send affordance per design — dark arrow on a light circle. + backgroundColor: colors.textPrimary + }, + sendButtonDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx new file mode 100644 index 00000000000..eadfd2043f8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -0,0 +1,371 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { + isImageRefBlock, + isTextBlock, + pairToolBlocks, + splitNativeChatBlocks, + type ToolPair +} from './mobile-native-chat-blocks' +import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' +import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from './mobile-native-chat-tool-summary' + +const MAX_VISIBLE_TOOL_PAIRS = 6 +const MAX_TOOL_RUN_DIFF_ROWS = 240 + +function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { + return ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + + {output.length > MAX_TOOL_RESULT_CHARS + ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` + : output} + + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const preview = call + ? summarizeToolInput(call.input) + : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = call ? toolFilePath(call.input) : null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {expanded ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {expanded ? ( + + {callDiff ? : null} + {!callDiff && call && preview ? {preview} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +function Prose({ + block, + invert, + fontScale, + onOpenFile +}: { + block: NativeChatBlock + invert?: boolean + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element | null { + if (isTextBlock(block)) { + // Inverted (user) bubbles use a fixed dark-on-light text rather than the + // markdown renderer's light-on-dark palette. + if (invert) { + return ( + {block.text} + ) + } + return ( + + ) + } + if (isImageRefBlock(block)) { + return ( + + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} + + ) + } + return null +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +function ToolRun({ + blocks, + defaultExpanded, + trailing, + onOpenFile +}: { + blocks: NativeChatBlock[] + defaultExpanded: boolean + trailing?: React.ReactNode + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [open, setOpen] = useState(defaultExpanded) + const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) + const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) + let callCount = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + callCount++ + } + } + callCount ||= pairs.length + const summary = summarizeToolRun(blocks) + return ( + + + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} + + + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} + +/** Subtle top-right controls for an agent message: copy its prose, or scroll so + * this message's top aligns to the top of the viewport. */ +function AgentControls({ + onCopy, + onScrollToTop +}: { + onCopy: () => void + onScrollToTop?: () => void +}): React.JSX.Element { + return ( + + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onCopy} + hitSlop={8} + accessibilityLabel="Copy message" + > + + + {onScrollToTop ? ( + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onScrollToTop} + hitSlop={8} + accessibilityLabel="Scroll this message to top" + > + + + ) : null} + + ) +} + +function MobileNativeChatMessageImpl({ + message, + queued, + toolsExpanded = false, + fontScale = 1, + messageIndex, + onScrollToMessage, + onOpenFile +}: { + message: NativeChatMessage + queued?: boolean + toolsExpanded?: boolean + /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ + fontScale?: number + /** This message's index in the list, paired with onScrollToMessage. */ + messageIndex?: number + /** Ask the list to align this message's top to the top of the viewport. */ + onScrollToMessage?: (index: number) => void + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const isUser = message.role === 'user' + const isReasoning = message.role === 'reasoning' + const isAgent = !isUser + // Briefly tint the bubble to confirm a copy landed. + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) + useEffect( + () => () => { + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + }, + [] + ) + // Separate the agent's words from its tool activity: prose renders first, the + // tool calls fold into a collapsible run beneath. The user's own messages get + // an inverted (filled accent) bubble so they stand apart from agent prose. + const { prose, tools } = splitNativeChatBlocks(message.blocks) + + const handleCopy = (): void => { + const text = nativeChatMessageText(message.blocks) + if (!text) { + return + } + void Clipboard.setStringAsync(text) + setCopied(true) + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + copyTimer.current = setTimeout(() => setCopied(false), 700) + } + + // Copy + scroll-to-top, shown inline with the first tool call (or after the + // prose when there are no tools). + const controls = + isAgent && !queued ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null + + return ( + + {isUser && queued ? Queued : null} + + {prose.map((block, index) => ( + + ))} + {tools.length > 0 ? ( + + ) : controls ? ( + {controls} + ) : null} + + + ) +} + +export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx new file mode 100644 index 00000000000..6acf2d305f6 --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, View } from 'react-native' +import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +type Props = { + controller: MobileNativeChatController + onAttachImage: () => void + isAttaching: boolean + onMicPress: () => void + micActive: boolean + dictationMode: 'toggle' | 'hold' + onMicPressIn: () => void + onMicPressOut: () => void + inputLockReason: MobileNativeChatInputLockReason | null + keyboardInset: number +} + +/** Keeps the terminal mounted underneath chat so its PTY subscription survives + * view toggles while the native surface owns the visible composer. */ +export function MobileNativeChatOverlay({ + controller, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + keyboardInset +}: Props): React.JSX.Element | null { + if (!controller.showNativeChat) { + return null + } + const session = controller.nativeChatSession + return ( + + + + ) +} + +const styles = StyleSheet.create({ + overlay: StyleSheet.absoluteFillObject +}) diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts new file mode 100644 index 00000000000..8e0223f19ec --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) + +describe('MobileNativeChatPermission', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('accepts only one response when two presses land in the same render batch', async () => { + let resolveResponse: (accepted: boolean) => void = () => {} + const response = new Promise((resolve) => (resolveResponse = resolve)) + const onRespond = vi.fn(() => response) + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) + }) + } finally { + consoleSpy.mockRestore() + } + const button = renderer.root.findByType('Pressable') + + act(() => { + button.props.onPress() + button.props.onPress() + }) + + expect(onRespond).toHaveBeenCalledOnce() + await act(async () => resolveResponse(true)) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx new file mode 100644 index 00000000000..ad26d578d93 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -0,0 +1,123 @@ +import { memo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileChatPermission } from './mobile-native-chat-permission' + +// Renders a detected agent permission ask as a card with tappable options. +// The first option is treated as the primary (allow) action and gets a filled +// accent button so the affirmative choice reads as distinct from the rest. +function MobileNativeChatPermissionImpl({ + permission, + onRespond +}: { + permission: MobileChatPermission + onRespond: (send: string) => Promise +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const respond = async (send: string): Promise => { + if (submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + const accepted = await onRespond(send) + if (!accepted) { + submittingRef.current = false + setSubmitting(false) + } + } + return ( + + + + {permission.title} + + {permission.detail ? {permission.detail} : null} + + {permission.options.map((option, index) => { + const isPrimary = index === 0 + return ( + [ + styles.option, + isPrimary ? styles.optionPrimary : styles.optionSecondary, + pressed && !submitting && styles.optionPressed + ]} + hitSlop={6} + onPress={() => respond(option.send)} + disabled={submitting} + > + + {option.label} + + + ) + })} + + + ) +} + +export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl) + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + detail: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: typography.metaSize + 5 + }, + options: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + option: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + optionPrimary: { + backgroundColor: colors.accentBlue + }, + optionSecondary: { + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionPressed: { + opacity: 0.7 + }, + optionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionTextPrimary: { + color: colors.onAccent + } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx new file mode 100644 index 00000000000..f470214dbed --- /dev/null +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' + +type Props = { + question: MobileChatQuestion + onAnswer: (text: string) => Promise +} + +/** Renders an agent's choice prompt as a tappable card. Single-select answers + * on tap; multi-select toggles then Submits; an always-present text entry lets + * the user answer freely (the escape hatch) when the heuristic misreads the + * options or none apply. */ +export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { + const [selected, setSelected] = useState([]) + const [freeText, setFreeText] = useState('') + const [sending, setSending] = useState(false) + const sendingRef = useRef(false) + + const hasOptions = question.options.length > 0 + const trimmedFreeText = freeText.trim() + + const toggle = (option: string): void => { + setSelected((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] + ) + } + + const sendAnswer = async (text: string): Promise => { + if (sendingRef.current) { + return false + } + sendingRef.current = true + setSending(true) + try { + return await onAnswer(text) + } finally { + sendingRef.current = false + setSending(false) + } + } + + const answerSingle = async (option: string): Promise => { + await sendAnswer(formatQuestionAnswer(question, [option])) + } + + const submitMulti = async (): Promise => { + if (selected.length === 0) { + return + } + await sendAnswer(formatQuestionAnswer(question, selected)) + } + + const submitFreeText = async (): Promise => { + if (trimmedFreeText.length === 0) { + return + } + // Free text is an unknown entry; formatQuestionAnswer passes it through. + if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + setFreeText('') + } + } + + const canSubmitMulti = selected.length > 0 && !sending + const canSendFreeText = trimmedFreeText.length > 0 && !sending + + // Stable keys for option rows even if an agent repeats a label. + const optionRows = useMemo( + () => question.options.map((label, index) => ({ label, key: `${index}:${label}` })), + [question.options] + ) + + return ( + + + + {question.question} + + + {hasOptions ? ( + + {optionRows.map(({ label, key }) => { + const isSelected = selected.includes(label) + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.pressed + ]} + onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))} + > + {question.multiSelect ? ( + + {isSelected ? : null} + + ) : null} + {label} + + ) + })} + + ) : null} + + {question.multiSelect && hasOptions ? ( + [ + styles.submit, + !canSubmitMulti && styles.submitDisabled, + pressed && canSubmitMulti && styles.pressed + ]} + onPress={submitMulti} + disabled={!canSubmitMulti} + > + + Submit{selected.length > 0 ? ` (${selected.length})` : ''} + + + ) : null} + + + + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + question: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + lineHeight: typography.bodySize + 7 + }, + options: { + gap: spacing.xs + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionSelected: { + borderColor: colors.accentBlue + }, + optionText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1 + }, + checkbox: { + width: 20, + height: 20, + borderRadius: radii.button, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkboxOn: { + backgroundColor: colors.accentBlue, + borderColor: colors.accentBlue + }, + submit: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.accentBlue + }, + submitDisabled: { + backgroundColor: colors.bgRaised + }, + submitText: { + color: colors.onMergeGreen, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + submitTextDisabled: { + color: colors.textMuted + }, + freeTextRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeSend: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.textPrimary + }, + freeSendDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx new file mode 100644 index 00000000000..1cfe37e5d4d --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Text, + View +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatAsk } from './MobileNativeChatAsk' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** Why the composer input is locked: the transport is disconnected, or the + * terminal subscription has not acknowledged its input lease yet. */ +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' + +type Props = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ + agent?: string | null + agentWorking?: boolean + /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ + onStop?: () => void + /** Live partial assistant text while a turn is still streaming (from the agent + * status hook). Shown as an in-progress bubble until the transcript catches up. */ + streamingText?: string + hasMore?: boolean + loadingEarlier?: boolean + onLoadEarlier?: () => void + onSend: (text: string) => Promise + /** Optimistic queued sends (owned by the route so they survive view switches). */ + pending: Array<{ id: string; text: string }> + /** Controlled composer text (owned by the route so dictation can write to it). */ + composerText: string + onComposerTextChange: (text: string) => void + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + inputLockReason?: MobileNativeChatInputLockReason | null + filePaths?: string[] + onNeedFiles?: (query: string) => void + /** A pending agent question/permission detected from live status, shown as a + * native card above the composer; answering sends text to the agent. */ + /** Structured AskUserQuestion prompt parsed from the transcript (preferred over + * the heuristic question card). */ + ask?: AskPrompt | null + /** Deliver the ask answer as per-question selections; the send hook turns them + * into selector keystrokes (Claude) or pasted label text (other agents). */ + onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + onCancelAsk?: () => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + /** Open a worktree file tapped in agent markdown. */ + onOpenFile?: (relativePath: string) => void + /** Pixels to lift the composer by when the soft keyboard is open. The route + * owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */ + keyboardInset?: number +} + +export function MobileNativeChatView({ + messages, + status, + error, + agent, + agentWorking, + onStop, + streamingText, + hasMore, + loadingEarlier, + onLoadEarlier, + onSend, + pending, + composerText, + onComposerTextChange, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + filePaths, + onNeedFiles, + ask, + onAnswerAsk, + onCancelAsk, + question, + onAnswerQuestion, + permission, + onRespondPermission, + onOpenFile, + keyboardInset = 0 +}: Props): React.JSX.Element { + const insets = useSafeAreaInsets() + const listRef = useRef>(null) + const [toolsExpanded, setToolsExpanded] = useState(false) + // Dismiss the question card as soon as it's answered; the live status lingers + // briefly (the agent emits a post-tool event with the same prompt), so hide it + // until a genuinely different question arrives. + const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) + // Lift the composer clear of the keyboard, plus the bottom safe-area so it + // never sits under the home indicator / nav bar (mirrors the terminal dock). + const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom + const [atBottom, setAtBottom] = useState(true) + const sendScrollTimerRef = useRef | null>(null) + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() + // Surface a rejected send inline above the composer — a bottom toast gets hidden + // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. + const [sendFailed, setSendFailed] = useState(false) + useEffect(() => { + if (!sendFailed) { + return + } + const t = setTimeout(() => setSendFailed(false), 4000) + return () => clearTimeout(t) + }, [sendFailed]) + + useEffect( + () => () => { + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + }, + [] + ) + + const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) + // `data` is the list source: folded transcript + synthetic streaming bubble + + // route-owned optimistic queued messages. Memoize on the same deps so the + // downstream autoscroll effects/`renderItem` keep referential stability. + const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const { data } = useMemo( + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), + [foldedMessages, streamingText, pending] + ) + + // Follow the tail as the conversation grows and keep the newest message above + // the keyboard when it opens — but only when already pinned to the bottom, so + // we don't yank the user away while they read history. (Also fires on keyboard + // close, which is harmless while atBottom.) + useEffect(() => { + if (data.length === 0 || !atBottom) { + return + } + const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) + return () => clearTimeout(t) + }, [data.length, atBottom, keyboardInset]) + + const handleSend = useCallback( + async (text: string): Promise => { + const accepted = await onSend(text) + if (!accepted) { + setSendFailed(true) + return false + } + setSendFailed(false) + // Always jump to the newest message when the user sends. + setAtBottom(true) + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + sendScrollTimerRef.current = setTimeout(() => { + sendScrollTimerRef.current = null + listRef.current?.scrollToEnd({ animated: true }) + }, 60) + return true + }, + [onSend] + ) + + const onScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) + setAtBottom(distanceFromBottom < 80) + // Near the top — page in older history. + if (contentOffset.y < 60 && hasMore && !loadingEarlier) { + onLoadEarlier?.() + } + }, + [hasMore, loadingEarlier, onLoadEarlier] + ) + + // Align a single message's top to the top of the viewport. + const onScrollToMessage = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true }) + }, []) + + const renderItem = useCallback( + ({ item, index }: { item: NativeChatMessage; index: number }) => ( + + ), + [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + ) + + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const showLoading = status === 'loading' && messages.length === 0 + + // Composer-lock flicker guard: on a remote link, brief connState blips or lease + // hand-offs would otherwise toggle the lock placeholder on and off. Only surface + // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + const rawLockReason = inputLockReason ?? null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockReason === null) { + setLockHeld(false) + return + } + const timer = setTimeout(() => setLockHeld(true), 600) + return () => clearTimeout(timer) + }, [rawLockReason]) + const lockReason = lockHeld ? rawLockReason : null + + return ( + + {showLoading ? ( + + + + ) : ( + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={32} + onContentSizeChange={() => { + if (data.length > 0 && atBottom) { + listRef.current?.scrollToEnd({ animated: false }) + } + }} + // scrollToIndex can fail before an off-screen row is measured — + // fall back to an estimated offset, then retry once it's laid out. + onScrollToIndexFailed={(info) => { + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true + }) + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + viewPosition: 0, + animated: true + }) + }, 120) + }} + ListHeaderComponent={ + hasMore ? ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) : null + } + ListEmptyComponent={ + emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + } + /> + + {/* Jump-to-latest control. The scroll-to-top affordance now lives + per-message (the up-arrow in each agent message's controls). */} + {!atBottom ? ( + listRef.current?.scrollToEnd({ animated: true })} + > + + + ) : null} + + )} + {/* Pending agent prompt: a structured AskUserQuestion wins, then a + heuristic permission, then a heuristic question. */} + {showAsk && ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) : null} + {/* Chrome row above the composer: the working indicator and the global + tool-calls expand/collapse toggle on the left, Stop in the far corner. */} + + + {agentWorking ? : null} + [styles.chromeToggle, pressed && styles.pressed]} + onPress={() => setToolsExpanded((v) => !v)} + hitSlop={8} + > + {toolsExpanded ? ( + + ) : ( + + )} + {toolsExpanded ? 'Collapse' : 'Tools'} + + + {agentWorking ? ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + hitSlop={8} + accessibilityLabel="Stop the agent" + > + + Stop + + ) : null} + + {sendFailed ? ( + + + {rawLockReason === 'disconnected' + ? 'Message not sent — reconnecting…' + : 'Message not sent'} + + + ) : null} + + + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 1bf3df59e74..9d725d5fe60 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess { } function clientWithResponses(responses: RpcResponse[]): Pick & { - calls: Array<{ method: string; params: unknown }> + calls: { method: string; params: unknown }[] } { - const calls: Array<{ method: string; params: unknown }> = [] + const calls: { method: string; params: unknown }[] = [] return { calls, sendRequest: vi.fn(async (method: string, params?: unknown) => { @@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/orca-attach.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) const sent = await attachMobileImageToTerminal('library', { @@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/x.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('files', { @@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/y.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('library', { @@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => { expect(sendCall?.params).not.toHaveProperty('client') }) - it('waits for pending live input before sending the image payload', async () => { + it('drops the image payload when the final pre-send check observes an input lease gap', async () => { const client = clientWithResponses([ { id: 'start', @@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => { }, ok('save', '/tmp/pending.png') ]) + // Why: upload can outlive the stream subscription whose acknowledgement + // originally enabled the composer. const beforeTerminalSend = vi.fn(async () => false) const sent = await attachMobileImageToTerminal('library', { @@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => { expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) }) + + it('reports failure when the terminal rejects the uploaded image path', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/rejected.png'), + ok('send', { send: { accepted: false } }) + ]) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-rejected', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' }) + }) + + expect(sent).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index b7d14d9a04d..567be99a9a1 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -4,6 +4,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' export type AttachMobileImageDeps = { readonly client: Pick @@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal( if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { return false } - await client.sendRequest('terminal.send', { + const response = await client.sendRequest('terminal.send', { terminal, text: payload, enter: false, ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {}) }) - return true + return isTerminalSendRpcAccepted(response) } diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts new file mode 100644 index 00000000000..05e05b1f188 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mobileNativeChatQuestionOffsets, + scheduleMobileClaudeAnswer, + MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, + MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, + MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS +} from './mobile-native-chat-answer-stepping' + +describe('mobileNativeChatQuestionOffsets', () => { + it('matches the desktop cadence constants', () => { + expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) + }) + + it('paces each question a full step apart, Enter 500ms after its body', () => { + expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) + }) +}) + +describe('scheduleMobileClaudeAnswer', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('writes each question body then its Enter, paced per step, one Enter per line', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['', 'b', 'c'], // blank middle answer must still get its own body + Enter + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + expect(timers).toHaveLength(6) + + vi.advanceTimersByTime(0) + expect(events).toEqual(['body:']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(events).toEqual(['body:', 'enter']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) + expect(events).toEqual(['body:', 'enter', 'body:b']) + + vi.runAllTimers() + expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) + }) + + it('cancelling the returned timers stops all pending writes', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['a', 'b'], + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + for (const timer of timers) { + clearTimeout(timer) + } + vi.runAllTimers() + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.ts b/mobile/src/session/mobile-native-chat-answer-stepping.ts new file mode 100644 index 00000000000..441f41291d7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.ts @@ -0,0 +1,13 @@ +import { + nativeChatQuestionOffsets, + scheduleNativeChatAnswer, + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../src/shared/native-chat-answer-stepping' + +export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS +export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS +export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS +export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets +export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts new file mode 100644 index 00000000000..6654a0e8689 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildAskAnswerKeys, + extractPendingAsk, + formatAskAnswer, + parseAskFromStatus +} from './mobile-native-chat-ask' + +function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } +} + +const askInput = { + questions: [ + { + question: 'Pick one', + header: 'Choice', + multiSelect: false, + options: [ + { label: 'A', description: 'first' }, + { label: 'B', description: 'second' } + ] + } + ] +} + +describe('extractPendingAsk', () => { + it('returns the structured prompt from a pending AskUserQuestion call', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }]) + ]) + expect(ask?.questions[0]).toMatchObject({ + question: 'Pick one', + header: 'Choice', + multiSelect: false + }) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null once a tool-result follows the ask', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'), + msg([{ type: 'tool-result', output: 'answered' }], 'r') + ]) + expect(ask).toBeNull() + }) + + it('ignores non-ask tool calls', () => { + expect( + extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])]) + ).toBeNull() + }) + + it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => { + // A parallel Bash call precedes the ask; its result resolves the Bash call + // (oldest outstanding), so the unanswered question must remain pending. + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1') + ]) + expect(ask?.questions[0]!.question).toBe('Pick one') + }) + + it("clears the ask only when the ask's own result arrives", () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1'), + msg([{ type: 'tool-result', output: 'answered' }], 'r2') + ]) + expect(ask).toBeNull() + }) + + it('keeps the latest ask when several appear', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'x' }], 'r1'), + msg([ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }] + } + } + ]) + ]) + expect(ask?.questions[0]!.question).toBe('Second') + }) +}) + +describe('parseAskFromStatus', () => { + it('parses the live interactivePrompt JSON into a prompt', () => { + const ask = parseAskFromStatus(JSON.stringify(askInput)) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null for empty or malformed input', () => { + expect(parseAskFromStatus(undefined)).toBeNull() + expect(parseAskFromStatus('')).toBeNull() + expect(parseAskFromStatus('{not json')).toBeNull() + expect(parseAskFromStatus('{"foo":1}')).toBeNull() + }) +}) + +describe('formatAskAnswer', () => { + it('joins selected labels per question', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }] } + ] + } + expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC') + }) + + it('keeps one line per question with a blank middle answer (3 questions)', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'B' }] }, + { question: 'q3', multiSelect: false, options: [{ label: 'C' }] } + ] + } + const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }]) + expect(answer).toBe('A\n\nC') + expect(answer.split('\n')).toHaveLength(3) + }) +}) + +describe('buildAskAnswerKeys', () => { + it('answers a single-select pick with its option number only', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }]) + }) + + it('toggles multi-select numbers then steps to Submit and confirms', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\x1b[C' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts new file mode 100644 index 00000000000..50766b25ee4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.ts @@ -0,0 +1,2 @@ +// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. +export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts new file mode 100644 index 00000000000..25c904c7b13 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +describe('detectAutocompleteTrigger', () => { + it('detects a slash command only at the start', () => { + expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ + kind: 'slash', + query: 'rev', + start: 0, + end: 4 + }) + expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() + }) + + it('detects an @ mention after whitespace or at start', () => { + expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) + expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ + kind: 'file', + query: 'comp' + }) + }) + + it('does not trigger @ mid-word (email-like)', () => { + expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() + }) + + it('closes the token once a space is typed', () => { + expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() + }) + + it('returns empty query right after the trigger char', () => { + expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) + }) +}) + +describe('applyAutocomplete', () => { + it('replaces the trigger span and leaves a trailing space + cursor', () => { + const trigger = detectAutocompleteTrigger('look at @comp', 13)! + const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') + expect(text).toBe('look at @src/App.tsx ') + expect(cursor).toBe(text.length) + }) +}) + +describe('rankSuggestions', () => { + it('prefers prefix matches on the basename', () => { + const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') + expect(out[0]).toBe('src/AppBar.tsx') + expect(out).toContain('lib/zapp.ts') + }) + + it('returns the head of the list for an empty query', () => { + expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts new file mode 100644 index 00000000000..de0ddc91f67 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -0,0 +1,93 @@ +// Why: the composer offers two kinds of autocomplete — `@file` mentions and +// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's +// unit-testable and the composer stays a thin view over it. + +export type AutocompleteKind = 'file' | 'slash' + +export type AutocompleteTrigger = { + kind: AutocompleteKind + /** The query typed after the trigger char (may be empty). */ + query: string + /** Index of the trigger char in the text (inclusive). */ + start: number + /** Index just past the cursor / token end (exclusive) — the replace span end. */ + end: number +} + +const TOKEN_CHAR = /[^\s]/ + +/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers + * anywhere it follows whitespace/start; a `/` command only at the very start of + * the input (slash commands are line-leading). Returns null when the cursor is + * not inside such a token. */ +export function detectAutocompleteTrigger( + text: string, + cursor: number +): AutocompleteTrigger | null { + const pos = Math.max(0, Math.min(cursor, text.length)) + // Walk left from the cursor over non-whitespace token chars to find the trigger. + let i = pos - 1 + while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { + i-- + } + const triggerIndex = i + 1 + const triggerChar = text[triggerIndex] + if (triggerChar !== '@' && triggerChar !== '/') { + return null + } + const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! + if (triggerChar === '/' && triggerIndex !== 0) { + // Slash commands are only offered at the very start of the message. + return null + } + if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { + return null + } + const query = text.slice(triggerIndex + 1, pos) + // A space in the query means the token already closed. + if (/\s/.test(query)) { + return null + } + return { + kind: triggerChar === '@' ? 'file' : 'slash', + query, + start: triggerIndex, + end: pos + } +} + +/** Replace the trigger span with `value`, leaving a trailing space and the + * cursor after it. Returns the new text and the new cursor position. */ +export function applyAutocomplete( + text: string, + trigger: AutocompleteTrigger, + value: string +): { text: string; cursor: number } { + const inserted = `${value} ` + const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) + return { text: next, cursor: trigger.start + inserted.length } +} + +/** Rank suggestions for a query: case-insensitive, prefix matches first, then + * substring matches, capped. Used for both file paths and command names. */ +export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { + const q = query.toLowerCase() + if (q.length === 0) { + return candidates.slice(0, limit) + } + const prefix: string[] = [] + const substring: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase() + const base = lower.split('/').pop() ?? lower + if (lower.startsWith(q) || base.startsWith(q)) { + prefix.push(candidate) + } else if (lower.includes(q)) { + substring.push(candidate) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts new file mode 100644 index 00000000000..57351e4a20c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks +} from './mobile-native-chat-blocks' + +function msg( + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + id: string = role +): NativeChatMessage { + return { id, role, blocks, timestamp: 0, source: 'transcript' } +} + +describe('splitNativeChatBlocks', () => { + it('separates prose from tool blocks', () => { + const { prose, tools } = splitNativeChatBlocks([ + { type: 'text', text: 'hi' }, + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(prose.map((b) => b.type)).toEqual(['text']) + expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result']) + }) +}) + +describe('pairToolBlocks', () => { + it('pairs each call with the following result', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a\nb' }, + { type: 'tool-call', name: 'Edit', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[0]!.call?.name).toBe('Bash') + expect(pairs[0]!.result?.output).toBe('a\nb') + expect(pairs[1]!.call?.name).toBe('Edit') + }) + + it('keeps an orphan result on its own', () => { + const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }]) + expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }]) + }) + + it('does not attach a second result to an already-filled pair', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: '1' }, + { type: 'tool-result', output: '2' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[1]!.call).toBeUndefined() + expect(pairs[1]!.result?.output).toBe('2') + }) + + it('pairs batched parallel calls by ordinal, not adjacency', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + // Parallel calls stream as [call, call, result, result]; the Nth result + // answers the Nth call, so result1 must land on call1 (not the nearer call2). + const pairs = pairToolBlocks([call1, call2, result1, result2]) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('does not graft a dropped over-limit call result onto a kept call', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + const result3 = { type: 'tool-result' as const, output: 'out-c' } + const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + // call3 was dropped past the limit, so result3 must not misgraft onto call2. + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('retains the last result while bounding rendered pairs', () => { + const blocks = Array.from({ length: 20 }, (_unused, index) => [ + { type: 'tool-call' as const, name: 'Bash', input: { index } }, + { type: 'tool-result' as const, output: String(index) } + ]).flat() + const pairs = pairToolBlocks(blocks, 6) + + expect(pairs).toHaveLength(6) + expect(pairs[5].result?.output).toBe('5') + }) +}) + +describe('foldToolMessages', () => { + it('folds tool-call-only assistant messages and result messages into the prose turn', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'), + msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'), + msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2') + ]) + expect(folded).toHaveLength(1) + expect(folded[0]!.blocks.map((b) => b.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result', + 'tool-call', + 'tool-result' + ]) + }) + + it('starts a new turn at the next prose assistant message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 't1' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('assistant', [{ type: 'text', text: 't2' }], 'a2') + ]) + expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) + }) + + it('keeps a tool message standalone when no assistant precedes it', () => { + const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) + expect(folded).toHaveLength(1) + expect(folded[0]!.role).toBe('tool') + }) + + it('does not merge across a user message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'), + msg('user', [{ type: 'text', text: 'q' }], 'u'), + msg('tool', [{ type: 'tool-result', output: 'r' }], 't') + ]) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + }) + + it('folds a long tool run without mutating the source assistant', () => { + const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') + const tools = Array.from({ length: 1_000 }, (_unused, index) => + msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) + ) + const folded = foldToolMessages([assistant, ...tools]) + + expect(folded[0].blocks).toHaveLength(1_001) + expect(assistant.blocks).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts new file mode 100644 index 00000000000..1cf293a79fd --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.ts @@ -0,0 +1,11 @@ +export { + isImageRefBlock, + isTextBlock, + NATIVE_CHAT_SOURCE_PRIORITY +} from '../../../src/shared/native-chat-types' +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair as ToolPair +} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts new file mode 100644 index 00000000000..3171b818050 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' + +describe('diffFromToolCall', () => { + it('builds del/add lines from an Edit tool call', () => { + const lines = diffFromToolCall('Edit', { + file_path: 'src/a.ts', + old_string: 'const a = 1\nconst b = 2', + new_string: 'const a = 10' + }) + expect(lines).toEqual([ + { kind: 'meta', text: 'src/a.ts' }, + { kind: 'del', text: 'const a = 1' }, + { kind: 'del', text: 'const b = 2' }, + { kind: 'add', text: 'const a = 10' } + ]) + }) + + it('returns null for non-edit tools', () => { + expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull() + }) +}) + +describe('diffFromText', () => { + it('parses unified diff lines with colours', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' context' }, + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ]) + }) + + it('does not treat ordinary prose as a diff', () => { + expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() + }) + + it('caps large diffs before creating render rows', () => { + const text = Array.from( + { length: 1_000 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + const lines = diffFromText(text) + + expect(lines).toHaveLength(120) + expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) + }) + + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts new file mode 100644 index 00000000000..4ace06d37d0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.ts @@ -0,0 +1,6 @@ +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts new file mode 100644 index 00000000000..387736f065e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { + canShowMobileNativeChat, + isMobileNativeChatTranscriptReadable, + resolveMobileNativeChat +} from './mobile-native-chat-eligibility' + +function status(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: 'tab:leaf', + ...overrides + } as AgentStatusEntry +} + +describe('resolveMobileNativeChat', () => { + it('prefers the authoritative supported live agent over a stale launch hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' } + } + } as never) + ).toMatchObject({ agent: 'codex', sessionId: 'codex-session' }) + }) + + it('rejects an unsupported live agent instead of combining it with a stale hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'gemini', + providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' } + } + } as never) + ).toBeNull() + }) + it('resolves agent + sessionId from launchAgent and provider session', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: status({ + providerSession: { + key: 'session_id', + id: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + } + }) + }) + ).toEqual({ + agent: 'claude', + sessionId: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + }) + }) + + it('falls back to agentStatus.agentType when no launchAgent', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + agentStatus: status({ agentType: 'codex' }) + }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null }) + }) + + it('admits OpenClaude with its distinct agent identity', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({ + agent: 'openclaude', + sessionId: null, + transcriptPath: null + }) + }) + + it('returns null for unsupported agents', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() + }) + + it('admits Grok only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'grok' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'grok' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'grok' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + }) + + it('returns null for a plain shell (no agent)', () => { + expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() + }) + + it('returns null for non-terminal tabs', () => { + expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() + }) + + it('canShowMobileNativeChat mirrors resolution', () => { + expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) + expect(canShowMobileNativeChat(null)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts new file mode 100644 index 00000000000..84e0e00d012 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -0,0 +1,66 @@ +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' +import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' + +// Why: native chat renders an agent's own JSONL transcript, and the host +// resolver knows these transcript layouts. Grok is additionally gated on host +// readability because Model-A SSH stores its transcript on the remote target. +export function isMobileNativeChatTranscriptReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} + +export type MobileNativeChatResolution = { + agent: string + /** The agent's own session id, or null before it has reported one (the view + * then shows a waiting state instead of trying to read an unaddressable file). */ + sessionId: string | null + /** Hook-reported transcript path. Recent Claude sessions cannot always be + * resolved from the provider session id, so mobile forwards this to runtime. */ + transcriptPath: string | null +} + +export type MobileNativeChatTab = { + type: string + launchAgent?: string | null + agentStatus?: AgentStatusEntry | null +} + +/** Resolve a session tab to the transcript identity native chat needs, or + * null when the tab can't show native chat (not a terminal, no agent, or an + * agent whose transcript the host can't read). Agent comes from the launch + * hint or the live status; session id from the captured provider session. */ +export function resolveMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): MobileNativeChatResolution | null { + if (!tab || tab.type !== 'terminal') { + return null + } + const liveAgent = tab.agentStatus?.agentType ?? null + const agent = liveAgent + ? isNativeChatSupportedAgent(liveAgent) + ? liveAgent + : null + : tab.launchAgent + if (!agent || !isNativeChatSupportedAgent(agent)) { + return null + } + if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + return null + } + return { + agent, + sessionId: tab.agentStatus?.providerSession?.id ?? null, + transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null + } +} + +/** Whether the tab can toggle into native chat — gates the long-press item. */ +export function canShowMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): boolean { + return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null +} diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts new file mode 100644 index 00000000000..61312670512 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mergeNativeChatMessages } from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +describe('mergeNativeChatMessages', () => { + it('appends new ids in arrival order', () => { + const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('replaces a re-emitted id in place without reordering', () => { + const merged = mergeNativeChatMessages( + [msg('a'), msg('b'), msg('c')], + [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] + ) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) + }) + + it('does not let a lower-priority source overwrite a higher one', () => { + const merged = mergeNativeChatMessages( + [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], + [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] + ) + expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) + }) + + it('returns the existing array unchanged for an empty batch', () => { + const existing = [msg('a')] + expect(mergeNativeChatMessages(existing, [])).toBe(existing) + }) + + it('does not mutate the existing array', () => { + const existing = [msg('a')] + mergeNativeChatMessages(existing, [msg('b')]) + expect(existing.map((m) => m.id)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts new file mode 100644 index 00000000000..6b6ffe1781e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.ts @@ -0,0 +1,9 @@ +// Keep the established mobile import path while executing the shared desktop/mobile merger. +export { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList, + type NativeChatMerger +} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts new file mode 100644 index 00000000000..2a034fd33f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the +// pure mergeNativeChatMessages for every prefix of an adversarial subscribe +// sequence — the cache must never diverge from a full rebuild (#18). +describe('mobile stateful merger — oracle vs pure merge', () => { + const base = [msg('a'), msg('b', { source: 'hook' })] + const batches: NativeChatMessage[][] = [ + [msg('c')], // pure tail + [], // empty frame + [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede + [msg('b', { source: 'scrape' })], // lower priority — ignored + [msg('a')], // re-emit, no change + [msg('d'), msg('e')] // multi tail + ] + + it('matches the pure merge for every prefix', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + let pure = mergeNativeChatMessages(base, []) + expect(merger.list).toEqual(pure) + for (const batch of batches) { + const out = applyAppend(merger, batch) + pure = mergeNativeChatMessages(pure, batch) + expect(out).toEqual(pure) + } + }) + + it('keeps prior row identity on a pure tail append', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + const before = merger.list + const tail = msg('z') + const after = applyAppend(merger, [tail]) + expect(after).not.toBe(before) + expect(after[0]).toBe(before[0]) + expect(after[after.length - 1]).toBe(tail) + }) + + it('bounds the window to the most-recent tail', () => { + const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`)) + expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9']) + expect(boundNativeChatWindow(long, 0)).toBe(long) + expect(boundNativeChatWindow(long, 20)).toBe(long) + }) + + it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, [msg('a'), msg('b'), msg('c')]) + + expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) + expect(merger.indexById.has('a')).toBe(false) + expect(merger.indexById.get('c')).toBe(0) + expect( + applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] + ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts new file mode 100644 index 00000000000..c67ffb738cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -0,0 +1,168 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const TEXT_SIZE = 17 +export const MONO_SIZE = 12 +export const MAX_TOOL_RESULT_CHARS = 4000 + +export const styles = StyleSheet.create({ + row: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + rowUser: { + alignItems: 'flex-end' + }, + content: { + maxWidth: '100%', + gap: spacing.sm + }, + userBubble: { + maxWidth: '88%', + backgroundColor: colors.textPrimary, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + userText: { + color: colors.bgBase, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6, + fontWeight: '500' + }, + controls: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginBottom: 2, + opacity: 0.7 + }, + controlButton: { + padding: 3 + }, + controlPressed: { + opacity: 0.5 + }, + copied: { + backgroundColor: colors.diffAddedBg, + borderRadius: radii.card + }, + reasoning: { + opacity: 0.7 + }, + queued: { + opacity: 0.55 + }, + queuedTag: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + marginBottom: 2 + }, + toolRun: { + marginTop: spacing.xs + }, + toolRunHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + toolRunToggle: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + toolRunCount: { + color: colors.statusGreen, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '700' + }, + toolRunLabel: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolRunBody: { + paddingLeft: spacing.sm, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + marginTop: spacing.xs + }, + toolLine: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + toolName: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + 1, + fontWeight: '600' + }, + toolPreview: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolPreviewLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + toolDetail: { + paddingLeft: spacing.lg, + paddingBottom: spacing.xs, + gap: spacing.xs + }, + mono: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5 + }, + toolResult: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md + }, + toolResultError: { + backgroundColor: colors.diffDeletedBg + }, + imageRef: { + color: colors.textSecondary, + fontSize: TEXT_SIZE + }, + diff: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.xs, + overflow: 'hidden' + }, + diffLine: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5, + paddingHorizontal: spacing.sm + }, + diffAdd: { + color: colors.gitDecorationAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.gitDecorationDeleted, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts new file mode 100644 index 00000000000..225904cc5d8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { + clampFontScale, + FONT_SCALE_MAX, + FONT_SCALE_MIN, + nativeChatMessageText +} from './mobile-native-chat-message-text' + +describe('nativeChatMessageText', () => { + it('joins text blocks and skips non-text blocks', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'text', text: 'World' } + ] + expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld') + }) + + it('returns an empty string when there is no prose', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }] + expect(nativeChatMessageText(blocks)).toBe('') + }) + + it('trims surrounding whitespace', () => { + expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi') + }) +}) + +describe('clampFontScale', () => { + it('clamps below the minimum', () => { + expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) + }) + + it('clamps above the maximum', () => { + expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) + }) + + it('passes through an in-range value', () => { + expect(clampFontScale(1.2)).toBe(1.2) + }) + + it('falls back to 1 for NaN', () => { + expect(clampFontScale(Number.NaN)).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts new file mode 100644 index 00000000000..26aeea7680c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -0,0 +1,24 @@ +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { isTextBlock } from './mobile-native-chat-blocks' + +/** Concatenate a message's text blocks into a single copyable string. Tool + * calls/results and image refs are skipped — Copy is for the agent's prose. */ +export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string { + return blocks + .filter(isTextBlock) + .map((b) => b.text) + .join('\n\n') + .trim() +} + +/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ +export const FONT_SCALE_MIN = 0.8 +export const FONT_SCALE_MAX = 1.8 + +/** Clamp a proposed font scale into the supported range. */ +export function clampFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) +} diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts new file mode 100644 index 00000000000..6977b764e72 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' + +function msg( + role: NativeChatMessage['role'], + text: string, + blocks?: NativeChatMessage['blocks'] +): NativeChatMessage { + return { + id: text.slice(0, 8), + role, + blocks: blocks ?? [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +describe('isNoiseMessage', () => { + it('flags task-notification and system-reminder user turns', () => { + expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) + expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) + expect( + isNoiseMessage( + msg( + 'user', + 'Caveat: The messages below were generated by the user while running local commands' + ) + ) + ).toBe(true) + expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) + }) + + it('flags inter-agent harness turns (teammate/agent messages)', () => { + expect(isNoiseMessage(msg('user', 'ping'))).toBe(true) + expect(isNoiseMessage(msg('user', 'status update'))).toBe(true) + }) + + it('keeps real user messages', () => { + expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + // A genuine custom tag is not a known harness tag, so it stays a user turn. + expect(isNoiseMessage(msg('user', ' is my band'))).toBe(false) + }) + + it('keeps assistant and tool turns', () => { + expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + }) + + it('keeps a user turn that carries tool results', () => { + expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) + }) + + it('stripNoiseMessages removes only the noise', () => { + const out = stripNoiseMessages([ + msg('user', 'hello'), + msg('user', 'done'), + msg('assistant', 'hi') + ]) + expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts new file mode 100644 index 00000000000..1b15a7e414a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.ts @@ -0,0 +1 @@ +export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts new file mode 100644 index 00000000000..2f8ceb936a7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + openMobileNativeChatFile, + resolveMobileNativeChatWorktreePath +} from './mobile-native-chat-open-file' + +describe('resolveMobileNativeChatWorktreePath', () => { + it('resolves an absolute tool path to a worktree-relative open target', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + ).resolves.toBe('src/app.ts') + expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { + worktree: 'id:worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + }) + + it('opens only the resolved worktree-relative target', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockResolvedValueOnce({ ok: true, result: {} }) + + await openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '../repo/src/app.ts', + terminal: 'terminal' + }) + + expect(sendRequest).toHaveBeenLastCalledWith('files.open', { + worktree: 'id:worktree', + relativePath: 'src/app.ts' + }) + }) + + it('resolves null when the resolve request rejects', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeNull() + }) + + it('does not reject when the open request fails', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockRejectedValueOnce(new Error('connection interrupted')) + + await expect( + openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts new file mode 100644 index 00000000000..44d6d23c066 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -0,0 +1,51 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { RpcClient } from '../transport/rpc-client' + +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + try { + const response = await args.client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${args.worktreeId}`, + pathText: args.pathText, + ...(args.terminal ? { terminal: args.terminal } : {}) + }) + if (!response.ok) { + return null + } + const resolved = response.result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return null + } + return resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + } catch { + // Callers fire-and-forget file opens; a disconnect/timeout must not become + // an unhandled rejection. + return null + } +} + +export async function openMobileNativeChatFile(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + const relativePath = await resolveMobileNativeChatWorktreePath(args) + if (relativePath) { + try { + await args.client.sendRequest('files.open', { + worktree: `id:${args.worktreeId}`, + relativePath + }) + } catch { + // Best-effort open; failures surface as a no-op rather than an + // unhandled rejection. + } + } +} diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts new file mode 100644 index 00000000000..2592038d7c9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send' + +describe('sendMobileNativeChatPermissionResponse', () => { + it('writes an approval as raw bytes without appending Return', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: 'phone', + text: '1' + }) + ).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts new file mode 100644 index 00000000000..679be2d94ed --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -0,0 +1,49 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +export function sendMobileNativeChatPermissionResponse(args: { + client: RpcClient + terminal: string + deviceToken: string | null + text: string +}): Promise { + // Why: approval choices are already complete terminal control sequences; + // appending Return changes both numbered choices and Escape denial. + return sendMobileNativeChatMessage({ + client: args.client, + terminal: args.terminal, + text: args.text, + enter: false, + ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) + }) +} + +export function useMobileNativeChatPermissionSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + onSendError: (message: string) => void +}): (text: string) => Promise { + return useCallback( + async (text: string): Promise => { + const terminal = args.handleRef.current + if (!args.client || !terminal || !args.enabled) { + args.onSendError('Response not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + if (!accepted) { + args.onSendError('Response not sent') + } + return accepted + }, + [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts new file mode 100644 index 00000000000..4744c394756 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' + +describe('detectAgentPermission', () => { + it('returns null for a working agent even with permission-like text', () => { + expect( + detectAgentPermission({ + state: 'working', + lastAssistantMessage: 'Do you want to proceed? (y/n)' + }) + ).toBeNull() + }) + + it('returns null when state is undefined', () => { + expect( + detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' }) + ).toBeNull() + }) + + it('returns null for ordinary prose while blocked', () => { + expect( + detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I finished editing the file and ran the tests.' + }) + ).toBeNull() + }) + + it('returns null when the message is empty', () => { + expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull() + }) + + it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)' + }) + expect(result).not.toBeNull() + expect(result?.title).toBe('Permission requested') + expect(result?.options).toEqual([ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ]) + expect(result?.detail).toContain('Allow running') + }) + + it('detects a numbered allow/deny menu and sends the digit', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Claude wants to edit App.tsx. Do you want to proceed?\n' + + '1. Yes\n' + + '2. No, and tell Claude what to do differently' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' }) + expect(result?.options[1]?.send).toBe('2') + expect(result?.options[1]?.label).toContain('No') + }) + + it('handles parenthesized numbered options "1) Yes 2) No"', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No' + }) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2']) + }) + + it('supports a three-option numbered menu (Yes / Yes always / No)', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + "Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No" + }) + expect(result?.options).toHaveLength(3) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3']) + }) + + it('adds an "Allow always" option when the text offers a persistent grant', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: + 'Do you want to allow this? You can allow always for this session. (y/n)' + }) + expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny']) + expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n']) + }) + + it('detects keyword-only permission asks without explicit y/n tokens', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I need your permission to run this Bash command.' + }) + expect(result).not.toBeNull() + expect(result?.options.map((o) => o.send)).toEqual(['y', 'n']) + }) + + it('detects "approve" / "deny" phrasing', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + }) + + it('truncates a very long detail line', () => { + const long = `Do you want to proceed? ${'x'.repeat(300)}` + const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long }) + expect(result).not.toBeNull() + expect((result?.detail ?? '').length).toBeLessThanOrEqual(160) + }) + + it('shortens long numbered option labels', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong' + }) + const second = result?.options[1] + expect(second?.send).toBe('2') + expect((second?.label ?? '').length).toBeLessThanOrEqual(40) + }) +}) + +describe('parseApprovalFromStatus', () => { + it('parses an approval envelope into an Allow/Deny card', () => { + const card = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + expect(card?.title).toBe('Allow Bash?') + expect(card?.detail).toBe('rm -rf build') + expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny']) + expect(card?.options[0]!.send).toBe('1') + }) + + it('returns null for non-approval / malformed input', () => { + expect(parseApprovalFromStatus(undefined)).toBeNull() + expect(parseApprovalFromStatus('{bad')).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts new file mode 100644 index 00000000000..fe3f9a0d89b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -0,0 +1,179 @@ +// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface +// as plain TUI text in the agent's last assistant message — there is no +// structured permission event on mobile. We detect them heuristically so the +// native chat can render tappable Allow/Deny buttons instead of forcing the +// user to type into the composer. Be conservative: only fire when the agent is +// actually paused (blocked/waiting) AND the text reads like an approval ask. + +/** A detected permission prompt, rendered as a card with tappable options. + * Each option's `send` is the literal string to write back to the agent + * (e.g. "y", "1") when the user taps it. */ +export type MobileChatPermission = { + title: string + detail?: string + options: Array<{ label: string; send: string }> +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the live `agentStatus.interactivePrompt` approval envelope + * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) + * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike + * detectAgentPermission it doesn't depend on heuristic text parsing. The default + * sends (number for allow, Escape for deny) match the common TUI approval prompt; + * detectAgentPermission still takes precedence when it can read the real numbered + * options from the prompt text. */ +export function parseApprovalFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const approval = (parsed as { approval?: unknown }).approval + if (!approval || typeof approval !== 'object') { + return null + } + const tool = (approval as { tool?: unknown }).tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = (approval as { summary?: unknown }).summary + return { + title: `Allow ${tool}?`, + detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: ESCAPE } + ] + } +} + +type PermissionInput = { + state?: string + lastAssistantMessage?: string + toolName?: string + toolInput?: unknown +} + +// States where the agent is paused waiting on the human. Only these can yield a +// permission prompt — a "working" agent is mid-turn and must not be answered. +const PAUSED_STATES: ReadonlySet = new Set(['blocked', 'waiting']) + +// Phrases that read as an approval request. Kept broad but anchored to +// approval/permission language so ordinary prose doesn't trip detection. +const PERMISSION_PATTERNS: RegExp[] = [ + /\bpermission\b/i, + /\bapprove\b/i, + /\bapproval\b/i, + /\ballow\b/i, + /\bdeny\b/i, + /\bgrant\b/i, + /\bauthorize\b/i, + /\bdo you want to\b/i, + /\bwould you like to\b/i, + /\bproceed\?/i, + /\bconfirm\b/i, + /\(y\/n\)/i, + /\by\/n\b/i, + /\byes\/no\b/i, + /\bplease confirm\b/i +] + +function looksLikePermissionAsk(text: string): boolean { + return PERMISSION_PATTERNS.some((re) => re.test(text)) +} + +// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…". +// Captures the option number and its label so we can send the literal digit. +const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g + +type NumberedOption = { num: string; text: string } + +function parseNumberedOptions(text: string): NumberedOption[] { + const out: NumberedOption[] = [] + for (const match of text.matchAll(NUMBERED_OPTION_RE)) { + const num = match[1] + const body = match[2]?.trim() + if (num && body) { + out.push({ num, text: body }) + } + } + return out +} + +// Whether a label reads as "allow for every future call" rather than just once. +function isAlwaysLabel(text: string): boolean { + return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text) +} + +function shortLabel(text: string, max = 40): string { + const trimmed = text.replace(/\s+/g, ' ').trim() + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed +} + +function buildNumberedPermission( + options: NumberedOption[], + detail: string | undefined +): MobileChatPermission { + return { + title: 'Permission requested', + detail, + options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num })) + } +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? '' +} + +/** + * Heuristically detect an agent permission ask from its paused-state context. + * Returns a renderable prompt, or null when the agent is working or the text + * doesn't read like an approval request. + */ +export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null { + // Only answer a genuinely paused agent. A working agent is mid-turn. + if (!input.state || !PAUSED_STATES.has(input.state)) { + return null + } + + const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : '' + if (!text.trim()) { + return null + } + + if (!looksLikePermissionAsk(text)) { + return null + } + + const detail = shortLabel(firstLine(text), 160) || undefined + + // Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels + // and send-digits come straight from the agent, so no guessing. + const numbered = parseNumberedOptions(text) + if (numbered.length >= 2) { + return buildNumberedPermission(numbered, detail) + } + + // Otherwise fall back to a y/n prompt. We surface "Allow always" only when the + // text actually offers a persistent option, to avoid sending a token the agent + // doesn't understand. + const options: MobileChatPermission['options'] = [ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ] + if (isAlwaysLabel(text)) { + options.splice(1, 0, { label: 'Allow always', send: 'a' }) + } + + return { title: 'Permission requested', detail, options } +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts new file mode 100644 index 00000000000..94fbcf055a9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswer, + mobileChatQuestionKey, + parseAgentQuestion, + type MobileChatQuestion +} from './mobile-native-chat-question' + +describe('parseAgentQuestion', () => { + it('parses a numbered list with a question line', () => { + const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join( + '\n' + ) + const q = parseAgentQuestion(text) + expect(q).not.toBeNull() + expect(q?.question).toBe('Which database should I use?') + expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite']) + expect(q?.optionTokens).toEqual(['1', '2', '3']) + expect(q?.multiSelect).toBe(false) + }) + + it('parses numbered lists using ) markers', () => { + const q = parseAgentQuestion('Pick:\n1) Yes\n2) No') + expect(q?.options).toEqual(['Yes', 'No']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('parses a bulleted list', () => { + const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash']) + expect(q?.optionTokens).toEqual([null, null, null]) + expect(q?.question).toBe('How should we proceed?') + }) + + it('parses lettered options in brackets and parens', () => { + const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta') + expect(q?.options).toEqual(['Alpha', 'Beta']) + expect(q?.optionTokens).toEqual(['a', 'b']) + + const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta') + expect(q2?.options).toEqual(['Alpha', 'Beta']) + expect(q2?.optionTokens).toEqual(['a', 'b']) + }) + + it('strips a leading pointer/highlight glyph from the selected row', () => { + const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['main', 'develop']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('drops a trailing colon but keeps a question mark in the title', () => { + expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options') + expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?') + }) + + it('returns null for ordinary prose with no option list', () => { + expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull() + expect(parseAgentQuestion('')).toBeNull() + expect(parseAgentQuestion(' ')).toBeNull() + }) + + it('returns null for a single bare option with no introducing prompt', () => { + // A lone "- item" in prose must not be treated as a question. + expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull() + }) + + it('accepts a single option when introduced by a prompt line', () => { + const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it') + expect(q).not.toBeNull() + expect(q?.options).toEqual(['Yes, apply it']) + }) + + it('uses a fallback title when no introducing line exists', () => { + const q = parseAgentQuestion('1. Alpha\n2. Beta') + expect(q?.question).toBe('Choose an option') + expect(q?.options).toEqual(['Alpha', 'Beta']) + }) + + it('detects multi-select hints', () => { + expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false) + }) + + it('does not flag multi-select when only one option is present', () => { + const q = parseAgentQuestion('Select all that apply?\n1. Only one') + expect(q?.multiSelect).toBe(false) + }) + + it('finds the question line nearest the options, skipping blanks', () => { + const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.question).toBe('Which branch?') + }) +}) + +describe('formatQuestionAnswer', () => { + const numbered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta', 'Gamma'], + multiSelect: false, + optionTokens: ['1', '2', '3'] + } + + it('echoes the leading token for single-select', () => { + expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2') + }) + + it('comma-joins tokens for multi-select', () => { + const multi: MobileChatQuestion = { ...numbered, multiSelect: true } + expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3') + }) + + it('sends the label text when the option had no token (bullet list)', () => { + const bullets: MobileChatQuestion = { + question: 'Pick', + options: ['Rebase', 'Merge'], + multiSelect: false, + optionTokens: [null, null] + } + expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge') + }) + + it('echoes letter tokens', () => { + const lettered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta'], + multiSelect: false, + optionTokens: ['a', 'b'] + } + expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b') + }) + + it('passes free-text / unknown entries through verbatim', () => { + expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom') + }) + + it('returns empty string when nothing is selected', () => { + expect(formatQuestionAnswer(numbered, [])).toBe('') + expect(formatQuestionAnswer(numbered, [' '])).toBe('') + }) +}) + +describe('mobileChatQuestionKey', () => { + it('changes when a replacement prompt changes any positional content', () => { + const first: MobileChatQuestion = { + question: 'Pick', + options: ['A', 'B'], + multiSelect: true, + optionTokens: ['1', '2'] + } + expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( + mobileChatQuestionKey(first) + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts new file mode 100644 index 00000000000..8a1f06dfbf7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -0,0 +1,154 @@ +// Heuristic detection of an agent's "pick an option" prompt from its status / +// assistant text. Agents (Claude et al.) render these as a TUI choice list; we +// have no structured signal, so we parse the text conservatively and only treat +// it as a question when a clear option list is present. + +export type MobileChatQuestion = { + question: string + options: string[] + multiSelect: boolean + /** Per-option leading marker ("1", "b", …) when the source line carried one, + * parallel to `options`. Null where the option was a plain bullet. Used to + * echo the exact choice the agent listed back to the terminal. */ + optionTokens: (string | null)[] +} + +export function mobileChatQuestionKey(question: MobileChatQuestion): string { + return JSON.stringify(question) +} + +type ParsedOption = { + label: string + token: string | null +} + +// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row. +// Stripped first so "❯ 2. Foo" parses identically to "2. Foo". +const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/ + +// Ordered most-specific-first so a numbered/lettered marker wins over the +// bullet fallback. `token` is the capture index of the leading marker (0 = none), +// `label` the capture index of the choice text. +const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [ + // 1. Option 12) Option + { re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // [a] Option [1] Option + { re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // a) Option a. Option (single letter, to avoid eating prose like "e.g.") + { re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // - Option * Option • Option > Option (no token) + { re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 } +] + +function parseOptionLine(line: string): ParsedOption | null { + const stripped = line.replace(POINTER_PREFIX, '$1') + for (const { re, token, label } of OPTION_PATTERNS) { + const m = stripped.match(re) + if (!m) { + continue + } + const text = m[label].trim() + if (text.length === 0) { + continue + } + return { label: text, token: token > 0 ? m[token] : null } + } + return null +} + +const MULTI_SELECT_HINT = + /\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i + +// Question-like introducing line: ends in ? or :. +const QUESTION_LINE = /[?:]\s*$/ + +// Drop a trailing ":" off a card title but keep a meaningful "?". +function cleanQuestionText(raw: string): string { + const trimmed = raw.trim() + return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed +} + +/** + * Heuristically parse a question + its option list from agent text. Returns null + * when no clear option list is present (so ordinary prose is never treated as a + * question). Conservative on purpose: requires at least two option lines, or one + * option line introduced by a question-like prompt line. + */ +export function parseAgentQuestion(text: string): MobileChatQuestion | null { + if (typeof text !== 'string' || text.trim().length === 0) { + return null + } + + const lines = text.replace(/\r\n/g, '\n').split('\n') + const parsed: { index: number; option: ParsedOption }[] = [] + + lines.forEach((line, index) => { + const option = parseOptionLine(line) + if (option) { + parsed.push({ index, option }) + } + }) + + if (parsed.length === 0) { + return null + } + + const firstOptionIndex = parsed[0].index + const options = parsed.map((p) => p.option.label) + const optionTokens = parsed.map((p) => p.option.token) + + // Find the introducing question: nearest non-empty, non-option line above the + // first option. + let question = '' + let questionLooksLikePrompt = false + for (let i = firstOptionIndex - 1; i >= 0; i--) { + if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) { + continue + } + question = lines[i] + questionLooksLikePrompt = QUESTION_LINE.test(lines[i]) + break + } + + // Conservative gate: a single bare option with no introducing prompt is more + // likely stray prose (a lone "- item") than a real choice list. + if (options.length < 2 && !questionLooksLikePrompt) { + return null + } + + const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1 + + return { + question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option', + options, + multiSelect, + optionTokens + } +} + +/** + * Build the text to send to the agent terminal for the selected option(s). + * Convention: echo the option's leading marker (number/letter) when the list had + * one; otherwise send the option label text (TUIs accept the literal choice). + * Multi-select answers are comma-joined; single-select is sent as-is. Unknown + * entries (free-text escape hatch) pass through verbatim. Returns '' when + * nothing is selected. + */ +export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string { + const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0) + if (labels.length === 0) { + return '' + } + + const parts = labels.map((label) => { + const index = question.options.indexOf(label) + if (index === -1) { + // Free-text / unknown entry: pass the user's text straight through. + return label + } + const token = question.optionTokens[index] + return token != null && token.length > 0 ? token : label + }) + + return parts.join(question.multiSelect ? ', ' : ' ') +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts new file mode 100644 index 00000000000..e933971220d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatData, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' } +} + +describe('mobileNativeChatEmptyState', () => { + it('invites a first message naming the agent, matching desktop copy', () => { + // waiting-session (live agent, no transcript) and ready (loaded, empty) both + // resolve to the shared "empty" copy with the agent label substituted. + const waiting = mobileNativeChatEmptyState('waiting-session', 'claude') + expect(waiting).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') + }) + + it('falls back to "the agent" when the agent is unknown', () => { + expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( + 'Start a chat with the agent' + ) + }) + + it('prefers the provided error message over the default subtitle', () => { + expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') + expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + }) + + it('returns null for states that show no empty copy', () => { + expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull() + expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull() + }) +}) + +describe('buildMobileNativeChatData', () => { + it('appends pending optimistic messages at the tail as user turns', () => { + const { data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'hello')], + streamingText: undefined, + pending: [{ id: 'p1', text: 'queued' }] + }) + const last = data[data.length - 1] + expect(last.id).toBe('p1') + expect(last.role).toBe('user') + expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }]) + }) + + it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [user('u1', 'hi')], + streamingText: 'thinking out loud', + pending: [] + }) + expect(streaming).toBe('thinking out loud') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('shows a short new streaming reply even after a longer previous turn', () => { + // The last folded turn is a long completed reply; a short new stream must not + // be suppressed just for being shorter than the prior turn. + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], + streamingText: 'Ok', + pending: [] + }) + expect(streaming).toBe('Ok') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('drops the streaming bubble once the real assistant turn already contains it', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'done answer')], + streamingText: 'done', + pending: [] + }) + expect(streaming).toBeNull() + expect(data.some((m) => m.id === 'streaming')).toBe(false) + }) + + it('returns no streaming bubble for empty/whitespace streaming text', () => { + expect( + buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming + ).toBeNull() + expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts new file mode 100644 index 00000000000..74ca01939ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -0,0 +1,115 @@ +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { + formatNativeChatEmptyStateCopy, + type NativeChatEmptyStateCopy +} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from './mobile-native-chat-blocks' +import { stripNoiseMessages } from './mobile-native-chat-noise' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** The centered empty-state copy for a chat with no messages, mirroring the + * desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces + * stay in lockstep. Returns null when the list should stay bare (idle, or the + * loading spinner owns the view). */ +export function mobileNativeChatEmptyState( + status: MobileNativeChatStatus, + agent: string | null, + error?: string +): NativeChatEmptyStateCopy | null { + const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' + switch (status) { + // A live agent with no transcript yet — and a loaded-but-empty transcript — + // are both "start a chat"; invite the first message instead of implying the + // agent is still starting up. + case 'waiting-session': + case 'ready': + return formatNativeChatEmptyStateCopy('empty', agentLabel) + case 'error': { + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + return error ? { ...copy, subtitle: error } : copy + } + default: + return null + } +} + +/** Derive the list data from the raw transcript: fold tool turns into the + * assistant turn, optionally append a synthetic streaming bubble, then the + * route-owned optimistic "queued" messages at the tail. Returns the + * intermediate `folded`/`streaming` so the caller can memoize on them. */ +export function buildMobileNativeChatData({ + messages, + streamingText, + pending +}: { + messages: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const folded = foldMobileNativeChatMessages(messages) + return buildMobileNativeChatTransientData({ folded, streamingText, pending }) +} + +export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + return foldToolMessages(stripNoiseMessages(messages)) +} + +export function buildMobileNativeChatTransientData({ + folded, + streamingText, + pending +}: { + folded: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + // Only show the streaming bubble while its text leads the transcript — once the + // real assistant turn lands with the same text, drop the synthetic one. + const streaming = deriveStreaming(folded, streamingText) + const data: NativeChatMessage[] = [ + ...folded, + ...(streaming + ? [ + { + id: 'streaming', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: streaming }], + timestamp: null, + source: 'hook' as const + } + ] + : []), + ...pending.map((p) => ({ + id: p.id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: p.text }], + timestamp: null, + source: 'transcript' as const + })) + ] + return { folded, streaming, data } +} + +function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { + const text = streamingText?.trim() + if (!text) { + return null + } + const last = folded[folded.length - 1] + const lastText = + last?.role === 'assistant' + ? last.blocks + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join('') + .trim() + : '' + // Hide the synthetic bubble only once the real turn has landed leading with the + // streamed text. A bare length compare would suppress a short new reply behind a + // longer previous turn; a completed prior turn won't start with the new prefix. + if (lastText.startsWith(text)) { + return null + } + return text +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts new file mode 100644 index 00000000000..01f4d5289ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +function clientWithResponse(response: unknown): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +describe('sendMobileNativeChatMessage', () => { + it('returns true only when the terminal accepts the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + mobileClient: { id: 'device', type: 'mobile' } + }) + ).resolves.toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }) + }) + + it('returns false when the terminal rejects the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('returns false when the RPC fails', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('sends a single non-submitting Escape for prompt cancellation', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts new file mode 100644 index 00000000000..4cf1776843e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -0,0 +1,27 @@ +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' + +type MobileTerminalClient = { + id: string + type: 'mobile' +} + +export async function sendMobileNativeChatMessage(args: { + client: RpcClient + terminal: string + text: string + enter?: boolean + mobileClient?: MobileTerminalClient +}): Promise { + try { + const response = await args.client.sendRequest('terminal.send', { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }) + return isTerminalSendRpcAccepted(response) + } catch { + return false + } +} diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 00000000000..8d7d1e7e07a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('uses the first snapshot as the ordered base and carries pagination state', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }) + }) + + it('merges reconnect snapshots and live appends into the bounded window', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + limit: 3, + replaceSnapshot: false + }) + + expect(result).toMatchObject({ + kind: 'messages', + messages: [message('b'), message('c'), message('d')], + cursorInvalidated: true + }) + }) + + it('keeps the pagination cursor when an append does not trim history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [message('b')] }, + limit: 3, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('a'), message('b')] }) + }) + + it('replaces stale history for an explicit transcript replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'replacement', messages: [message('new')], hasMore: false }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + }) + + it('surfaces snapshot errors and ignores unrelated frames', () => { + const merger = createNativeChatMerger() + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', error: 'Transcript unavailable' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'error', error: 'Transcript unavailable' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'error', message: 'Socket closed' }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'error', error: 'Socket closed' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'end' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'ignored' }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts new file mode 100644 index 00000000000..690994f02f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -0,0 +1,63 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' + +export type MobileNativeChatStreamFrame = { + type?: string + messages?: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + error?: string + message?: string +} + +export type AppliedMobileNativeChatFrame = + | { kind: 'ignored' } + | { kind: 'error'; error: string } + | { + kind: 'messages' + messages: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + cursorInvalidated?: boolean + } + +/** Applies runtime stream frames while preserving the initial-snapshot versus + * reconnect-replay distinction owned by the session hook. */ +export function applyMobileNativeChatStreamFrame(args: { + merger: NativeChatMerger + frame: MobileNativeChatStreamFrame + limit: number + replaceSnapshot: boolean +}): AppliedMobileNativeChatFrame { + const { merger, frame, limit, replaceSnapshot } = args + if (frame.type === 'error') { + return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } + } + if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { + return { kind: 'ignored' } + } + if (frame.error) { + return { kind: 'error', error: frame.error } + } + if (!Array.isArray(frame.messages)) { + return { kind: 'ignored' } + } + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + replaceList(merger, frame.messages) + return { + kind: 'messages', + messages: merger.list, + hasMore: frame.hasMore, + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + } + const previousFirstId = merger.list[0]?.id + const messages = applyAppend(merger, frame.messages, limit) + return { + kind: 'messages', + messages, + // Why: once the bounded live window drops its oldest row, the snapshot's + // byte cursor no longer describes the oldest retained message. + ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..cc92c6b3aca --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalCoveredByNativeChat, + mobileNativeChatTerminalCapabilities, + resolveMobileNativeChatTerminalStreamAction +} from './mobile-native-chat-terminal-stream' + +const base = { + showNativeChat: false, + activeHandle: 'pty-1', + activeTabType: 'terminal', + streamActive: true, + streamCovered: false, + webViewReady: true +} + +describe('mobile native-chat terminal stream lifecycle', () => { + it('pauses an active terminal stream while chat covers it', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe( + 'pause' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false + }) + ).toBe('pause') + expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true) + expect(mobileNativeChatTerminalCapabilities(true)).toEqual({ + terminalBinaryStream: 1, + mobileInputLeaseOnly: 1 + }) + expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) + }) + + it('records a cold-start cover before WebView readiness so return refreshes', () => { + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + webViewReady: false + }) + ).toBe('pause') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: true, + streamCovered: true + }) + ).toBe('resume') + }) + + it('resumes only the ready active terminal after chat closes', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe( + 'resume' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: false, + webViewReady: false + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe( + 'resume' + ) + }) + + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { + expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe( + 'none' + ) + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( + 'none' + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..387fe9c3580 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -0,0 +1,37 @@ +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' + +/** Decides whether the active mobile terminal stream should run while native chat + * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ +export function resolveMobileNativeChatTerminalStreamAction(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + streamActive: boolean + streamCovered: boolean + webViewReady: boolean +}): MobileNativeChatTerminalStreamAction { + if (!args.activeHandle || args.activeTabType !== 'terminal') { + return 'none' + } + if (args.showNativeChat) { + return !args.streamCovered ? 'pause' : 'none' + } + return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' +} + +export function isTerminalCoveredByNativeChat( + showNativeChat: boolean, + activeHandle: string | null, + handle: string +): boolean { + return showNativeChat && activeHandle === handle +} + +export function mobileNativeChatTerminalCapabilities(covered: boolean): { + terminalBinaryStream: 1 + mobileInputLeaseOnly?: 1 +} { + return covered + ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + : { terminalBinaryStream: 1 } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts new file mode 100644 index 00000000000..32ae700671a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -0,0 +1,37 @@ +import { MessageSquare, SquareTerminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' + +type ToggleTab = MobileNativeChatTab & { + id: string + terminal: string | null +} + +/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */ +export function getMobileNativeChatToggleActions(args: { + terminalHandle: string | null + tabs: readonly ToggleTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onClose: () => void + onToggle: (tabId: string) => void +}): ActionSheetAction[] { + const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const tab = terminalHandle + ? tabs.find((candidate) => candidate.terminal === terminalHandle) + : null + if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { + return [] + } + const isChat = chatTabIds.has(tab.id) + return [ + { + label: isChat ? 'Switch to terminal view' : 'Switch to chat view', + icon: isChat ? SquareTerminal : MessageSquare, + onPress: () => { + onClose() + onToggle(tab.id) + } + } + ] +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..d08deec4b31 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' + +describe('summarizeToolInput', () => { + it('passes short strings through, collapsing whitespace', () => { + expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp') + }) + + it('serializes objects to compact JSON', () => { + expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}') + }) + + it('truncates long previews with an ellipsis', () => { + const long = 'x'.repeat(200) + const out = summarizeToolInput(long) + expect(out.length).toBe(80) + expect(out.endsWith('…')).toBe(true) + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeToolInput(null)).toBe('') + expect(summarizeToolInput(undefined)).toBe('') + }) + + it('handles numbers and booleans', () => { + expect(summarizeToolInput(42)).toBe('42') + expect(summarizeToolInput(true)).toBe('true') + }) + + it('does not traverse an unbounded tool-input object for a short preview', () => { + const input: Record = Object.fromEntries( + Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index]) + ) + const lateGetter = vi.fn(() => 'should not be read') + Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter }) + + expect(summarizeToolInput(input)).not.toBe('') + expect(lateGetter).not.toHaveBeenCalled() + }) +}) + +describe('toolFilePath', () => { + it('extracts file_path / filePath / path / notebook_path', () => { + expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts') + expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts') + expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts') + expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb') + }) + it('returns null when there is no file target', () => { + expect(toolFilePath({ command: 'ls' })).toBeNull() + expect(toolFilePath('x')).toBeNull() + expect(toolFilePath(null)).toBeNull() + }) +}) + +describe('briefToolArg', () => { + it('takes the basename of a forward-slash path', () => { + expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') + }) + + it('takes the basename of a Windows backslash path from a raw transcript', () => { + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts new file mode 100644 index 00000000000..bc3f7a2f89a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -0,0 +1,7 @@ +export { + briefToolArg, + countToolCalls, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts new file mode 100644 index 00000000000..160477cfad8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -0,0 +1,109 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + }, + chromeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 28, + paddingHorizontal: spacing.md + }, + chromeLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stopButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + stopLabel: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '700' + }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, + chromeToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: spacing.xs + }, + chromeToggleLabel: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + pressed: { + opacity: 0.6 + }, + listWrap: { + flex: 1, + position: 'relative' + }, + listContent: { + paddingVertical: spacing.sm, + flexGrow: 1 + }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyTitle: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.xs + }, + emptySubtitle: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + fab: { + position: 'absolute', + right: spacing.md, + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + fabBottom: { + bottom: spacing.md + }, + loadEarlier: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + minHeight: 36 + }, + loadEarlierText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts new file mode 100644 index 00000000000..3b29a6dfaf7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -0,0 +1,69 @@ +import { Eraser, Monitor, Smartphone } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action' + +type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } + +/** Builds the terminal long-press menu without adding another action block to the + * already dense session route. Native chat stays first as the view switch. */ +export function getMobileTerminalActionSheetActions(args: { + target: Target | null + tabs: readonly TerminalTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onDismiss: () => void + onToggleChat: (tabId: string) => void + isPhoneMode: (handle: string) => boolean + onToggleDisplayMode: (handle: string) => void + onRename: (target: Target) => void + onClear: (target: Target) => void + onClose: (target: Target) => void +}): ActionSheetAction[] { + const { target } = args + if (!target) { + return [] + } + const phoneMode = args.isPhoneMode(target.handle) + return [ + ...getMobileNativeChatToggleActions({ + terminalHandle: target.handle, + tabs: args.tabs, + chatTabIds: args.chatTabIds, + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, + onClose: args.onDismiss, + onToggle: args.onToggleChat + }), + { + label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone', + icon: phoneMode ? Monitor : Smartphone, + onPress: () => { + args.onDismiss() + args.onToggleDisplayMode(target.handle) + } + }, + { + label: 'Rename', + onPress: () => { + args.onDismiss() + args.onRename(target) + } + }, + { + label: 'Clear Terminal', + icon: Eraser, + onPress: () => { + args.onDismiss() + args.onClear(target) + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + args.onDismiss() + args.onClose(target) + } + } + ] +} diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.test.ts b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts new file mode 100644 index 00000000000..8d3785efdcf --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' + +describe('subscribeMobileTerminalSafely', () => { + it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => { + const onSynchronousError = vi.fn() + const client = { + subscribe: vi.fn(() => { + throw new Error('socket closed') + }) + } as unknown as Pick + + const unsubscribe = subscribeMobileTerminalSafely( + client, + { terminal: 'terminal-1' }, + vi.fn(), + onSynchronousError + ) + + expect(onSynchronousError).toHaveBeenCalledOnce() + expect(unsubscribe).not.toThrow() + }) +}) diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.ts b/mobile/src/session/mobile-terminal-stream-subscribe.ts new file mode 100644 index 00000000000..189b07a74f0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.ts @@ -0,0 +1,17 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function subscribeMobileTerminalSafely( + client: Pick, + params: unknown, + onData: Parameters[2], + onSynchronousError: () => void +): () => void { + try { + return client.subscribe('terminal.subscribe', params, onData) + } catch { + // Why: a transport mock or closing socket can reject before returning an + // unsubscribe handle; callers must still release their subscribing marker. + onSynchronousError() + return () => {} + } +} diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts new file mode 100644 index 00000000000..a69d66accdd --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' + +type Gate = (targetHandle: string) => Promise + +describe('useMobileAttachmentInputLeaseGate', () => { + let renderer: ReactTestRenderer | null = null + let errorSpy: ReturnType | null = null + + beforeEach(() => { + const original = console.error + errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...(args as Parameters)) + }) + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + errorSpy?.mockRestore() + }) + + function renderGate(args: { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + showToast: (message: string, durationMs?: number) => void + }): { gate: () => Gate } { + let gate: Gate = () => Promise.resolve(false) + function Probe(): null { + gate = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true), + connStateRef: args.connState, + activeHandleRef: args.activeHandle, + activeSessionTabTypeRef: args.tabType, + nativeChatInputLeaseReadyRef: args.leaseReady, + showToast: args.showToast + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { gate: () => gate } + } + + function baseRefs(): { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + } { + return { + connState: { current: 'connected' }, + activeHandle: { current: 'terminal-1' }, + tabType: { current: 'terminal' }, + leaseReady: { current: true } + } + } + + it('passes immediately when the lease is ready and the target is active', async () => { + const refs = baseRefs() + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + await expect(gate()('terminal-1')).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('waits out a lease-not-ready window and then sends', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('surfaces a toast when the lease never recovers', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500) + }) + + it('drops silently when the target changes while waiting for the lease', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + // Mid-wait the user switches tabs: the lease recovers, but for a different + // target — the attach must not proceed against the stale handle. + await vi.advanceTimersByTimeAsync(200) + refs.activeHandle.current = 'terminal-2' + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) + + it('drops silently when the connection is lost while waiting', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.connState.current = 'reconnecting' + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts new file mode 100644 index 00000000000..a0ae29b6c51 --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' + +type CurrentRef = { readonly current: T } + +type AttachmentInputLeaseGateArgs = { + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly connStateRef: CurrentRef + readonly activeHandleRef: CurrentRef + readonly activeSessionTabTypeRef: CurrentRef + readonly nativeChatInputLeaseReadyRef: CurrentRef + readonly showToast: (message: string, durationMs?: number) => void +} + +// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or +// return-to-terminal) during which the input lease is briefly not ready. +const LEASE_READY_POLL_MS = 100 +const LEASE_READY_TIMEOUT_MS = 3000 + +/** Gates an image attachment's terminal.send on a ready input lease. Flushes any + * pending IME/live input, confirms the send still targets the connected terminal + * tab, then waits out a short lease-not-ready window so a finished upload isn't + * dropped as if the picker were cancelled. Returns false only when the lease + * never recovers — surfacing a toast, since the caller treats a bare false as a + * silent picker-cancel (no error path). */ +export function useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast +}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise { + return useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + if ( + !flushedPendingInput || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + const deadline = Date.now() + LEASE_READY_TIMEOUT_MS + while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS)) + } + // Why: the wait can outlive the target too — re-check so a tab/host switch + // or disconnect mid-wait doesn't send into the wrong (or dead) terminal. + // A moved-away target drops silently like the pre-wait guard; only a lease + // that never recovered warrants the toast. + if ( + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + if (nativeChatInputLeaseReadyRef.current) { + return true + } + showToast('Attach failed (reconnecting)', 1500) + return false + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + connStateRef, + flushPendingLiveInputBeforeExternalSend, + nativeChatInputLeaseReadyRef, + showToast + ] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts new file mode 100644 index 00000000000..79a11b6d723 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -0,0 +1,243 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +type AnswerSend = ReturnType + +function acceptedResponse() { + return { + id: 'send', + ok: true as const, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } +} + +const TABS_OR_SPACES: AskPrompt = { + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('useMobileNativeChatAnswerSend', () => { + let renderer: ReactTestRenderer | null = null + let answerSend: AnswerSend | null = null + let mountedClient: RpcClient | null = null + let mountedOnSendError: ((message: string) => void) | null = null + let mountedAgent: AgentType = 'claude' + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + answerSend = null + mountedClient = null + mountedOnSendError = null + mountedAgent = 'claude' + vi.useRealTimers() + }) + + function Harness({ enabled }: { enabled: boolean }): null { + answerSend = useMobileNativeChatAnswerSend({ + client: mountedClient, + enabled, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef: { current: mountedAgent }, + sessionId: 'session', + streamIdentity: 'host\0worktree\0tab\0session', + onSendError: mountedOnSendError! + }) + return null + } + + async function mount( + client: RpcClient, + onSendError: (message: string) => void, + agent: AgentType = 'claude' + ): Promise { + mountedClient = client + mountedOnSendError = onSendError + mountedAgent = agent + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function setEnabled(enabled: boolean): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { enabled })) + }) + } + + it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + }) + + it('multi-question: option numbers auto-advance, one final submit Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '1', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let result: Promise | undefined + await act(async () => { + // A newline in raw keystrokes would submit early — must collapse to space. + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '3', enter: false }), + expect.objectContaining({ text: 'zeta spaces', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('answers OpenClaude asks with Claude selector keystrokes', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('submits a non-Claude answer as pasted label text with a single Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + // Codex's question tool commits the pasted answer: label text + one Enter. + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + }) + + it('stops at the first rejected write and reports failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + }) + + it('rejects an empty selection without writing anything', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await setEnabled(false) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts new file mode 100644 index 00000000000..255f60de3a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' + +/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is + * answered by option-number keystrokes; other agents get pasted label text. + * Extracted from the session route to keep that file under its line cap and to + * own the pending-timer lifecycle in one place. */ +export type MobileNativeChatAnswerSend = { + /** Answer the current question(s) from the card's per-question selections. */ + answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + /** Drop any in-flight per-keystroke writes (call on Stop). */ + cancelPending: () => void +} + +// A free-text answer is written as raw keystrokes into Claude's "Type something" +// input (terminal.send has no paste framing), so an embedded newline would +// submit it early — collapse line breaks to spaces. +function sanitizeAskFreeText(text: string): string { + return text.replace(/[\r\n]+/g, ' ') +} + +/** + * Owns the ask-answer send sequence for the mobile native chat. Reads the live + * pane/agent through refs (the route already keeps them current) so the returned + * callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys` + * keystroke groups written one selector-step apart over the EXISTING + * `terminal.send` passthrough (raw text, no enter) — same contract the + * permission card already uses, so old runtimes replay them verbatim (no new + * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so + * a detached chain can never write PTY bytes to a stale pane. + */ +export function useMobileNativeChatAnswerSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): MobileNativeChatAnswerSend { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + sessionId, + streamIdentity, + onSendError + } = args + const generationRef = useRef(0) + const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) + activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + const delaysRef = useRef< + Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> + >(new Set()) + + const cancelPending = useCallback(() => { + generationRef.current += 1 + for (const delay of delaysRef.current) { + clearTimeout(delay.timer) + delay.resolve(false) + } + delaysRef.current.clear() + }, []) + + // Cancel pending writes on unmount and whenever the chat session swaps. + useEffect(() => { + if (!enabled) { + cancelPending() + } + return cancelPending + }, [client, enabled, sessionId, streamIdentity, cancelPending]) + + const answerAsk = useCallback( + async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Answer not sent (disconnected)') + return false + } + if (!hasAskAnswer(prompt, selections)) { + return false + } + // A new answer supersedes any still-pending keystroke writes. + cancelPending() + const generation = generationRef.current + const sendTerminal = (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { + return Promise.resolve(false) + } + return sendMobileNativeChatMessage({ + client, + terminal: handle, + text: body, + enter, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + } + const wait = (ms: number): Promise => + new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + const fail = (): false => { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + // Non-Claude question tools commit a pasted answer, so send the label text + // with one Enter. Claude's arrow-navigate selector ignores pasted labels + // (STA-1860): drive it by option-number keystrokes instead, one group per + // selector step so each renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + } + const groups = buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + } + return groups.length > 0 + }, + [ + agentRef, + enabled, + cancelPending, + client, + deviceTokenRef, + handleRef, + onSendError, + sessionId, + streamIdentity + ] + ) + + return { answerAsk, cancelPending } +} diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts new file mode 100644 index 00000000000..8c81f0255c2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' + +describe('useMobileNativeChatAskDismiss', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ prompt }: { prompt: AskPrompt }): null { + state = useMobileNativeChatAskDismiss(prompt) + return null + } + + const first: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'old second', multiSelect: false, options: [] } + ] + } + const replacement: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'new second', multiSelect: false, options: [] } + ] + } + + it('shows a structurally different replacement without an intervening null', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { prompt: first })) + }) + } finally { + consoleSpy.mockRestore() + } + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + expect(state?.showAsk).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts new file mode 100644 index 00000000000..8f7289f0753 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type { AskPrompt } from './mobile-native-chat-ask' + +/** Track the answered-ask key so the lingering live status doesn't re-show the + * same card. The agent emits a post-tool event with the same prompt right after + * an answer, so the card is hidden until a genuinely different question arrives. */ +export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + askKey: string | null + showAsk: boolean + dismissAsk: () => void +} { + const askKey = ask ? JSON.stringify(ask.questions) : null + const [dismissedAskKey, setDismissedAskKey] = useState(null) + // Once the prompt clears (agent moved on), forget the dismissal so a later + // question — even an identical one — shows again instead of staying hidden. + const askPresent = ask != null + useEffect(() => { + if (!askPresent) { + setDismissedAskKey(null) + } + }, [askPresent]) + const showAsk = askPresent && askKey !== dismissedAskKey + const dismissAsk = (): void => setDismissedAskKey(askKey) + + return { askKey, showAsk, dismissAsk } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts new file mode 100644 index 00000000000..cca9ae3f923 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import type { RpcClient } from '../transport/rpc-client' +import { + parseAskFromStatus, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { detectAgentPermission } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' +import { openMobileNativeChatFile } from './mobile-native-chat-open-file' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 + +export type MobileNativeChatController = { + chatTabIds: Set + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: Array<{ id: string; text: string }> + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + nativeChatAsk: ReturnType + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatSend: (text: string) => Promise +} + +/** Owns mobile native-chat state and teardown outside the already dense session + * route. The route remains responsible only for choosing and rendering the view. */ +export function useMobileNativeChatController(args: { + client: RpcClient | null + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + deviceTokenRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean + nativeChatInputLeaseReady: boolean + onSendError: (message: string) => void +}): MobileNativeChatController { + const { + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError + } = args + const [chatTabIds, setChatTabIds] = useState>(new Set()) + const chatTabIdsToggledRef = useRef(false) + + useEffect(() => { + let active = true + // Re-arm per host/worktree so the fresh load can seed the new selection. + chatTabIdsToggledRef.current = false + void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { + // Why: a toggle before this load resolves is authoritative; the load must + // not revert the user's in-memory choice back to the persisted state. + if (active && !chatTabIdsToggledRef.current) { + // Why: route reuse must clear the prior worktree's selection even when + // the newly loaded worktree has no saved chat tabs. + setChatTabIds(new Set(ids)) + } + }) + return () => { + active = false + } + }, [hostId, worktreeId]) + + const toggleTabChatView = useCallback( + (tabId: string) => { + chatTabIdsToggledRef.current = true + setChatTabIds((previous) => { + const next = new Set(previous) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + void saveNativeChatTabIds(hostId, worktreeId, [...next]) + return next + }) + }, + [hostId, worktreeId] + ) + + const activeChatResolution = + activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + showNativeChatRef.current = showNativeChat + const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) + activeChatAgentRef.current = activeChatResolution?.agent ?? null + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + + const nativeChatSession = useMobileNativeChatSession({ + client, + agent: activeChatResolution?.agent ?? null, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }) + const { + composerText: chatComposerText, + setComposerText: setChatComposerText, + pending: chatPending, + captureSendOrigin, + acceptSend + } = useMobileNativeChatDrafts({ + hostId, + worktreeId, + tabId: activeSessionTabId, + sessionId: activeChatSessionId, + messages: nativeChatSession.messages + }) + + const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null + const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Throttle the streaming bubble: OpenCode emits a status frame per streamed + // part, and each one re-renders and re-parses the whole accumulated markdown. + const nativeChatStreamingText = useThrottledLatestValue( + nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + NATIVE_CHAT_STREAM_THROTTLE_MS + ) + const { + permission: nativeChatPermission, + question: nativeChatQuestion, + ask: nativeChatAsk + } = useMobileNativeChatPrompts({ + enabled: activeChatResolution != null, + status: nativeChatStatus, + messages: nativeChatSession.messages + }) + + const handleNativeChatOpenFile = useCallback( + (pathText: string) => { + if (!client) { + return + } + void openMobileNativeChatFile({ + client, + worktreeId, + pathText, + terminal: activeHandleRef.current + }) + }, + [activeHandleRef, client, worktreeId] + ) + + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = + useMobileNativeChatAnswerSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, + streamIdentity, + onSendError + }) + + const handleNativeChatCancelAsk = useCallback(async (): Promise => { + const handle = activeHandleRef.current + if (!client || !handle || !nativeChatInputLeaseReady) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelNativeChatAnswer() + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Cancel not sent') + } + return accepted + }, [ + activeHandleRef, + cancelNativeChatAnswer, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ]) + + const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + onSendError + }) + + const handleNativeChatStop = useMobileNativeChatStop({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + streamIdentity, + cancelPending: cancelNativeChatAnswer, + onSendError + }) + + const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ + client, + worktreeId + }) + + const handleNativeChatSend = useCallback( + async (text: string): Promise => { + const handle = activeHandleRef.current + const origin = captureSendOrigin(text) + if (!client || !handle || !origin || !nativeChatInputLeaseReady) { + onSendError('Message not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Message not sent') + return false + } + acceptSend(origin, text) + return true + }, + [ + acceptSend, + activeHandleRef, + captureSendOrigin, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ] + ) + + return { + chatTabIds, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + nativeChatAgent: activeChatResolution?.agent ?? null, + chatComposerText, + setChatComposerText, + chatPending, + nativeChatSession, + nativeChatAgentWorking, + nativeChatStreamingText, + nativeChatPermission, + nativeChatQuestion, + nativeChatAsk, + handleNativeChatOpenFile, + handleNativeChatAnswerAsk, + handleNativeChatCancelAsk, + handleNativeChatRespondPermission, + handleNativeChatStop, + nativeChatFilePaths, + loadNativeChatFiles, + handleNativeChatSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts new file mode 100644 index 00000000000..1505c9d1dc4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -0,0 +1,192 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function assistantTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('useMobileNativeChatDrafts', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [] + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function switchTo(tabId: string): Promise { + await act(async () => renderer?.update(createElement(Harness, { tabId }))) + } + + it('keeps drafts and accepted pending messages on their originating tabs', async () => { + await mount('a') + act(() => state?.setComposerText('from a')) + const originA = state?.captureSendOrigin('from a') + expect(originA).not.toBeNull() + + await switchTo('b') + act(() => state?.setComposerText('from b')) + act(() => { + if (originA) { + state?.acceptSend(originA, 'from a') + } + }) + expect(state?.composerText).toBe('from b') + expect(state?.pending).toEqual([]) + + await switchTo('a') + expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.text)).toEqual(['from a']) + }) + + it('clears one pending per landed message so duplicate sends are not all dropped', async () => { + await mount('a') + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + }) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + }) + + it('does not reconcile a repeated send against an older identical turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [ + userTextMessage('old', 'ping'), + assistantTextMessage('other', 'working'), + userTextMessage('new', 'ping') + ] + }) + ) + ) + expect(state?.pending).toEqual([]) + }) + + it('does not erase newer edits when an older send settles', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => state?.setComposerText('new edit')) + act(() => { + if (origin) { + state?.acceptSend(origin, 'submitted') + } + }) + + expect(state?.composerText).toBe('new edit') + }) + + it('accepts and clears the first send before a provider session id exists', async () => { + await mount('a') + await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) + act(() => state?.setComposerText('start the session')) + + const origin = state?.captureSendOrigin('start the session') + expect(origin).toMatchObject({ pendingKey: null }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'start the session') + } + }) + + expect(state?.composerText).toBe('') + expect(state?.pending).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts new file mode 100644 index 00000000000..ddd730e227c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number +} +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number +} + +const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] + +function normalizedUserText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + return text || null +} + +function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number { + let count = 0 + for (const message of messages) { + if (normalizedUserText(message) === text) { + count++ + } + } + return count +} + +export function useMobileNativeChatDrafts(args: { + hostId: string + worktreeId: string + tabId: string | null + sessionId: string | null + messages: readonly NativeChatMessage[] +}): { + composerText: string + setComposerText: Dispatch> + pending: MobileNativeChatPendingMessage[] + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +} { + const { hostId, worktreeId, tabId, sessionId, messages } = args + const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null + const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null + const [drafts, setDrafts] = useState>({}) + const [pendingBySession, setPendingBySession] = useState< + Record + >({}) + const pendingCounterRef = useRef(0) + const messagesRef = useRef(messages) + messagesRef.current = messages + + const setComposerText: Dispatch> = useCallback( + (value) => { + if (!draftKey) { + return + } + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? previous : { ...previous, [draftKey]: next } + }) + }, + [draftKey] + ) + + const captureSendOrigin = useCallback( + (text: string) => { + if (!draftKey) { + return null + } + const normalizedText = text.trim() + return { + draftKey, + pendingKey, + normalizedText, + baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + } + }, + [draftKey, pendingKey] + ) + + const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { + // Why: an RPC may settle after a tab switch; mutate only the tab that + // originated the send, without erasing edits typed after it began. + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + // Why: the first prompt can be sent before the provider reports a session + // id; clear its draft, but wait for an id before keying an optimistic echo. + if (!origin.pendingKey) { + return + } + const pendingKey = origin.pendingKey + pendingCounterRef.current += 1 + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? NO_PENDING_MESSAGES + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const pending = { + id: `pending-${pendingCounterRef.current}`, + text, + expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1 + } + return { ...previous, [pendingKey]: [...current, pending] } + }) + }, []) + + const pending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + useEffect(() => { + if (!pendingKey || pending.length === 0) { + return + } + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? [] + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + // Why: compare against the count captured before send; historical equal + // turns cannot clear a new echo, while duplicates land one occurrence each. + const next = current.filter( + (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence + ) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [pendingKey]: next } + } + const remaining = { ...previous } + delete remaining[pendingKey] + return remaining + }) + }, [messages, pending, pendingKey]) + + return { + composerText: draftKey ? (drafts[draftKey] ?? '') : '', + setComposerText, + pending, + captureSendOrigin, + acceptSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts new file mode 100644 index 00000000000..e0500019844 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +type SearchState = ReturnType + +function rpcSuccess(files: string[]): Awaited> { + return { + id: 'files', + ok: true, + result: { files: files.map((relativePath) => ({ relativePath })) }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('useMobileNativeChatFileSearch', () => { + let renderer: ReactTestRenderer | null = null + let state: SearchState | null = null + + async function mount(client: RpcClient): Promise { + function Harness(): null { + state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) + return null + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + }) + } finally { + restore() + } + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('coalesces rapid queries and retains only the bounded host result', async () => { + const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts'])) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => { + state?.loadNativeChatFiles('a') + state?.loadNativeChatFiles('app') + }) + await act(async () => vi.advanceTimersByTimeAsync(119)) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', { + worktree: 'id:wt-1', + query: 'app', + limit: 16 + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts']) + }) + + it('loads the legacy inventory once when an older host lacks searchPaths', async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(['src/apple.ts', 'docs/readme.md']) + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) + + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => { + const sendRequest = vi.fn(async (_method: string, params: { query: string }) => + rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts']) + ) + await mount({ sendRequest } as unknown as RpcClient) + + // Populate the cache for 'app'. + act(() => state?.loadNativeChatFiles('app')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'. + act(() => { + state?.loadNativeChatFiles('beta') + state?.loadNativeChatFiles('app') + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // The cancelled 'beta' request must never fire and overwrite the cached result. + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + expect( + sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta') + ).toHaveLength(0) + }) + + it('coalesces overlapping legacy inventory requests on a slow host', async () => { + let resolveList: (value: Awaited>) => void = () => {} + const listResponse = new Promise>>((resolve) => { + resolveList = resolve + }) + const sendRequest = vi.fn((method: string) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return listResponse + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + + await act(async () => { + resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md'])) + await Promise.resolve() + await Promise.resolve() + }) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts new file mode 100644 index 00000000000..53df98e3ee0 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { rankSuggestions } from './mobile-native-chat-autocomplete' + +const FILE_SEARCH_DEBOUNCE_MS = 120 +const FILE_SEARCH_RESULT_LIMIT = 16 +const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 + +function extractPaths(result: unknown): string[] { + const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) +} + +/** Debounces current-host path searches, bounds the mobile result/cache, and + * falls back to the legacy one-time full list when paired to an older host. */ +export function useMobileNativeChatFileSearch(args: { + client: RpcClient | null + worktreeId: string +}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } { + const { client, worktreeId } = args + const [nativeChatFilePaths, setNativeChatFilePaths] = useState([]) + const timerRef = useRef | null>(null) + const sequenceRef = useRef(0) + const generationRef = useRef(0) + const queryCacheRef = useRef(new Map()) + const legacyPathsRef = useRef(null) + const legacyLoadRef = useRef | null>(null) + const searchSupportedRef = useRef(null) + + useEffect(() => { + sequenceRef.current++ + generationRef.current++ + queryCacheRef.current.clear() + legacyPathsRef.current = null + legacyLoadRef.current = null + searchSupportedRef.current = null + setNativeChatFilePaths([]) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, worktreeId]) + + const loadNativeChatFiles = useCallback( + (query: string) => { + if (!client) { + return + } + const normalizedQuery = query.trim().toLowerCase().slice(0, 256) + const cached = queryCacheRef.current.get(normalizedQuery) + if (cached) { + // Why: cancel and stale-out any in-flight debounced query so an older + // request cannot later clobber this displayed cached result. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + sequenceRef.current++ + setNativeChatFilePaths(cached) + return + } + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const sequence = ++sequenceRef.current + const generation = generationRef.current + setNativeChatFilePaths([]) + timerRef.current = setTimeout(() => { + timerRef.current = null + const applyPaths = (paths: string[]): void => { + if (sequenceRef.current !== sequence) { + return + } + queryCacheRef.current.set(normalizedQuery, paths) + while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) { + const oldest = queryCacheRef.current.keys().next().value as string | undefined + if (!oldest) { + break + } + queryCacheRef.current.delete(oldest) + } + setNativeChatFilePaths(paths) + } + const loadLegacyPaths = async (): Promise => { + if (!legacyPathsRef.current) { + if (!legacyLoadRef.current) { + const request = client + .sendRequest('files.list', { worktree: `id:${worktreeId}` }) + .then((response) => { + if (!response.ok || generationRef.current !== generation) { + return null + } + const paths = extractPaths(response.result) + legacyPathsRef.current = paths + return paths + }) + .finally(() => { + if (legacyLoadRef.current === request && !legacyPathsRef.current) { + legacyLoadRef.current = null + } + }) + // Why: older hosts expose only the full inventory RPC; queries that + // overlap its slow local/SSH read must share one request. + legacyLoadRef.current = request + } + const paths = await legacyLoadRef.current + if (!paths) { + return + } + } + const legacyPaths = legacyPathsRef.current + if (legacyPaths) { + applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + } + } + void (async () => { + if (searchSupportedRef.current === false) { + await loadLegacyPaths() + return + } + const response = await client.sendRequest('files.searchPaths', { + worktree: `id:${worktreeId}`, + query: normalizedQuery, + limit: FILE_SEARCH_RESULT_LIMIT + }) + if (response.ok) { + searchSupportedRef.current = true + applyPaths(extractPaths(response.result)) + return + } + if (response.error.code === 'method_not_found') { + searchSupportedRef.current = false + await loadLegacyPaths() + } + })().catch(() => {}) + }, FILE_SEARCH_DEBOUNCE_MS) + }, + [client, worktreeId] + ) + + return { nativeChatFilePaths, loadNativeChatFiles } +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts new file mode 100644 index 00000000000..bb90327c5b9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' + +type Lease = ReturnType + +describe('useMobileNativeChatInputLease', () => { + let renderer: ReactTestRenderer | null = null + let lease: Lease | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + lease = null + }) + + function Harness({ connected }: { connected: boolean }): null { + lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected }) + return null + } + + it('unlocks only after acknowledgement and clears on disconnect', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() + + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts new file mode 100644 index 00000000000..02dfeff84a8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +export function useMobileNativeChatInputLease(args: { + activeHandle: string | null + connected: boolean +}): { + ready: boolean + readyRef: { readonly current: boolean } + lockReason: MobileNativeChatInputLockReason | null + markReady: (handle: string) => void + clear: (handle?: string) => void +} { + const [readyHandles, setReadyHandles] = useState>(new Set()) + const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) + // Why: absence of an acknowledgement proves only that setup is still pending; + // the protocol does not report evidence that another client owns the floor. + const lockReason: MobileNativeChatInputLockReason | null = !args.connected + ? 'disconnected' + : ready + ? null + : 'waiting' + const readyRef = useRef(ready) + readyRef.current = ready + useEffect(() => { + if (!args.connected) { + setReadyHandles(new Set()) + } + }, [args.connected]) + const markReady = useCallback((handle: string) => { + setReadyHandles((current) => new Set(current).add(handle)) + }, []) + const clear = useCallback((handle?: string) => { + setReadyHandles((current) => { + if (handle === undefined) { + return new Set() + } + if (!current.has(handle)) { + return current + } + const next = new Set(current) + next.delete(handle) + return next + }) + }, []) + return { + ready, + readyRef, + lockReason, + markReady, + clear + } +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts new file mode 100644 index 00000000000..3b3cdb2e8a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef, useState } from 'react' +import { Gesture } from 'react-native-gesture-handler' +import type { ComposedGesture } from 'react-native-gesture-handler' +import { clampFontScale } from './mobile-native-chat-message-text' + +/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` + * anchors the live gesture so successive pinches compound rather than reset. */ +export function useMobileNativeChatPinchGesture(): { + fontScale: number + pinchGesture: ComposedGesture +} { + const [fontScale, setFontScale] = useState(1) + const fontScaleRef = useRef(1) + fontScaleRef.current = fontScale + const pinchBase = useRef(1) + // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so + // they can touch React refs/state and clampFontScale directly — accessing those + // from the UI-thread worklet crashes the app. + // Compose the pinch with the list's native scroll as Simultaneous so a + // two-finger pinch is recognized even while the scroll view is active — + // otherwise the scroll grabs the gesture first and the pinch never fires. + const pinchGesture = useMemo( + () => + Gesture.Simultaneous( + Gesture.Native(), + Gesture.Pinch() + .runOnJS(true) + .onStart(() => { + pinchBase.current = fontScaleRef.current + }) + .onUpdate((e) => { + setFontScale(clampFontScale(pinchBase.current * e.scale)) + }) + ), + [] + ) + + return { fontScale, pinchGesture } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts new file mode 100644 index 00000000000..11a17696bfc --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' + +export type MobileNativeChatPrompts = { + permission: ReturnType + question: ReturnType + ask: ReturnType +} + +/** Derives the prompt cards shown above the composer. */ +export function useMobileNativeChatPrompts(args: { + enabled: boolean + status: AgentStatusEntry | null | undefined + messages: readonly NativeChatMessage[] +}): MobileNativeChatPrompts { + const { enabled, status, messages } = args + const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const permission = + (blocked && status + ? detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) + : null) ?? parseApprovalFromStatus(status?.interactivePrompt) + const question = + blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null + const askFromStatus = useMemo( + () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), + [status?.interactivePrompt, status?.toolName] + ) + const askFromMessages = useMemo( + () => (askFromStatus ? null : extractPendingAsk(messages)), + [askFromStatus, messages] + ) + + return { + permission, + question, + ask: enabled ? (askFromStatus ?? askFromMessages) : null + } +} diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts new file mode 100644 index 00000000000..46feaf79217 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -0,0 +1,107 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' + +describe('useMobileNativeChatReadability', () => { + let renderer: ReactTestRenderer | null = null + let readable = false + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + readable = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount(connectionId: string | null): Promise { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) + } as unknown as RpcClient + function Harness(): null { + readable = useMobileNativeChatReadability(client, 'repo::/worktree') + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('admits local and runtime-owned transcript hosts', async () => { + await mount(null) + expect(readable).toBe(true) + act(() => renderer?.unmount()) + renderer = null + + await mount('runtime-ssh-environment') + expect(readable).toBe(true) + }) + + it('fails closed for Model-A SSH transcript hosts', async () => { + await mount('model-a-ssh') + expect(readable).toBe(false) + }) + + it('fails closed immediately while a reused route resolves its new worktree', async () => { + let resolveNext: (response: unknown) => void = () => {} + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repos: [{ id: 'local-repo', connectionId: null }] } + }) + .mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve))) + } as unknown as RpcClient + function Harness({ worktreeId }: { worktreeId: string }): null { + readable = useMobileNativeChatReadability(client, worktreeId) + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() + }) + expect(readable).toBe(true) + + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } + }) + await Promise.resolve() + }) + expect(readable).toBe(false) + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts new file mode 100644 index 00000000000..e952335fe23 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' + +type RepoSummary = { id: string; connectionId?: string | null } +type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } + +export function useMobileNativeChatReadability( + client: RpcClient | null, + worktreeId: string +): boolean { + const [state, setState] = useState({ + client: null, + worktreeId: '', + readable: false + }) + useEffect(() => { + let active = true + if (!client) { + setState({ client, worktreeId, readable: false }) + return + } + void client + .sendRequest('repo.list') + .then((response) => { + if (!active) { + return + } + const repos = response.ok + ? ((response.result as { repos?: RepoSummary[] }).repos ?? []) + : [] + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repo = repos.find((candidate) => candidate.id === repoId) + setState({ + client, + worktreeId, + readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + }, [client, worktreeId]) + // Why: route reuse renders before its new effect resolves; never expose the + // previous repo's readability under a different client/worktree key. + return state.client === client && state.worktreeId === worktreeId ? state.readable : false +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts new file mode 100644 index 00000000000..584f34624f2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -0,0 +1,220 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useMobileNativeChatSession', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + let emit: (frame: unknown) => void = () => {} + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ client }: { client: RpcClient | null }): null { + state = useMobileNativeChatSession({ + client, + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + return null + } + + async function mount(client: RpcClient): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('drops an older-page response captured before transcript replacement', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + await act(async () => { + emit({ + type: 'replacement', + messages: [message('replacement')], + hasMore: false, + beforeOffset: 0 + }) + }) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 } + }) + await Promise.resolve() + }) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement']) + expect(state?.loadingEarlier).toBe(false) + }) + + it('drops an older-page response after the client source disappears', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => renderer?.update(createElement(Harness, { client: null }))) + await act(async () => { + resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } }) + await Promise.resolve() + }) + + expect(state?.messages).toEqual([]) + expect(state?.status).toBe('idle') + expect(state?.loadingEarlier).toBe(false) + }) + + it.each(['replacement', 'snapshot'] as const)( + 'can page again after an authoritative %s resets a maxed-out read window', + async (frameType) => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 50 } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + for (let page = 0; page < 33; page += 1) { + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + } + const requestsAtCap = sendRequest.mock.calls.length + + await act(async () => + emit({ + type: frameType, + messages: [message('authoritative')], + hasMore: true, + beforeOffset: 500 + }) + ) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 60, + beforeOffset: 500 + }) + } + ) + + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { + let resolveCursorPage: (response: unknown) => void = () => {} + const sendRequest = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve))) + .mockResolvedValueOnce({ + ok: true, + result: { messages: [message('fresh-growing-tail')], hasMore: false } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => emit({ type: 'appended', messages: [message('live-trim')] })) + await act(async () => { + resolveCursorPage({ + ok: true, + result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 } + }) + await Promise.resolve() + }) + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page') + + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts new file mode 100644 index 00000000000..2d381fdd602 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' +import type { RpcClient } from '../transport/rpc-client' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { + applyMobileNativeChatStreamFrame, + type MobileNativeChatStreamFrame +} from './mobile-native-chat-stream-frame' + +export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' + +export type MobileNativeChatSession = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** True when an older page may exist (the last read filled the window). */ + hasMore: boolean + /** Whether an older-history page is currently loading. */ + loadingEarlier: boolean + /** Grow the window to page in older history. */ + loadEarlier: () => void +} + +// Small first page for a fast first paint; grows by a page as the user scrolls. +const INITIAL_LIMIT = 40 +const PAGE = 60 +const MAX_MESSAGES = 2000 + +type ReadSessionResult = + | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } + | { error: string } +/** Subscribe to an agent's native-chat transcript over the paired connection. + * Reads a small recent window for a fast first paint, tails it for live turns, + * and pages in older history on demand. Read results replace the list (they are + * an ordered tail); live appends merge by id so order stays stable. */ +export function useMobileNativeChatSession(args: { + client: RpcClient | null + agent: string | null + sessionId: string | null + transcriptPath: string | null +}): MobileNativeChatSession { + const { client, agent, sessionId, transcriptPath } = args + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [loadingEarlier, setLoadingEarlier] = useState(false) + const loadingEarlierRef = useRef(false) + const beforeOffsetRef = useRef(null) + // Stateful id-dedup merger: caches the id→index map so each live append frame + // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the + // base (read / loadEarlier ordered tails); `applyAppend` folds live frames in. + const mergerRef = useRef(createNativeChatMerger()) + const limitRef = useRef(INITIAL_LIMIT) + // Tracks the live session so a late loadEarlier resolve can detect a swap. + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const streamGenerationRef = useRef(0) + + // Replace the base list (read results are an ordered tail). Resets the merger + // cache so the index is rebuilt once over the new base. + const setList = useCallback((next: readonly NativeChatMessage[]) => { + replaceList(mergerRef.current, next) + setMessages(mergerRef.current.list) + }, []) + + useEffect(() => { + let cancelled = false + // Why: disconnect/agent/session loss must invalidate a page request before + // the early idle/waiting return can clear the visible source. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + setList([]) + setError(undefined) + setHasMore(false) + beforeOffsetRef.current = null + if (!client || !agent) { + setStatus('idle') + return + } + if (!sessionId) { + setStatus('waiting-session') + return + } + + setStatus('loading') + + const unsubscribe = client.subscribe( + 'nativeChat.subscribe', + { + agent, + sessionId, + limit: limitRef.current, + subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + ...(transcriptPath ? { transcriptPath } : {}) + }, + (raw) => { + if (cancelled) { + return + } + const frame = raw as MobileNativeChatStreamFrame + if (frame.type === 'replacement' || frame.type === 'snapshot') { + // Why: replacement and reconnect snapshots are authoritative windows; + // stale page limits/results must not constrain the fresh generation. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + const replaceSnapshot = frame.type === 'snapshot' + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setStatus('error') + setError(applied.error) + return + } + setMessages(applied.messages) + if (applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setStatus('ready') + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, [client, agent, sessionId, transcriptPath, setList]) + + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + const requestGeneration = streamGenerationRef.current + const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES) + const pageLimit = nextLimit - limitRef.current + if (pageLimit <= 0) { + setHasMore(false) + return + } + const beforeOffset = beforeOffsetRef.current + loadingEarlierRef.current = true + setLoadingEarlier(true) + void (async () => { + try { + const response = await client.sendRequest('nativeChat.readSession', { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }) + if (!response.ok) { + return + } + const result = response.result as ReadSessionResult + if ('error' in result) { + return + } + // Drop a stale resolve from a session that swapped underneath us. + if ( + sessionIdRef.current !== requestSessionId || + streamGenerationRef.current !== requestGeneration + ) { + return + } + limitRef.current = nextLimit + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + setHasMore( + nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + ) + } else { + // Older runtimes ignore the cursor and return the growing tail. + setList(result.messages) + setHasMore(result.messages.length >= nextLimit) + } + } finally { + // A late page from a prior tab must not unlock the current tab's request. + if ( + sessionIdRef.current === requestSessionId && + streamGenerationRef.current === requestGeneration + ) { + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + } + })() + }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + + return { messages, status, error, hasMore, loadingEarlier, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts new file mode 100644 index 00000000000..27385f8d33e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' + +describe('useMobileNativeChatStop', () => { + let renderer: ReactTestRenderer | null = null + let stop: (() => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendRequest.mockReset().mockResolvedValue({ ok: true }) + onSendError.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + stop = null + vi.useRealTimers() + }) + + function Harness({ + enabled, + streamIdentity + }: { + enabled: boolean + streamIdentity: string + }): null { + stop = useMobileNativeChatStop({ + client: { sendRequest } as unknown as RpcClient, + enabled, + handleRef: { current: 'terminal-1' }, + deviceTokenRef: { current: 'mobile-1' }, + streamIdentity, + cancelPending: vi.fn(), + onSendError + }) + return null + } + + async function render(enabled: boolean, streamIdentity: string): Promise { + await act(async () => { + const element = createElement(Harness, { enabled, streamIdentity }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it.each([ + ['the acknowledged input lease is lost', false, 'stream-1'], + ['the active stream changes', true, 'stream-2'] + ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { + await render(true, 'stream-1') + + act(() => stop?.()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await render(enabled as boolean, streamIdentity as string) + await act(async () => vi.runAllTimersAsync()) + + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('handles a rejected Escape without leaking an unhandled rejection', async () => { + sendRequest.mockRejectedValue(new Error('disconnected')) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts new file mode 100644 index 00000000000..c6d36ea3436 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' + +export function useMobileNativeChatStop(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + streamIdentity: string + cancelPending: () => void + onSendError: (message: string) => void +}): () => void { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = + args + const timerRef = useRef | null>(null) + const activeRouteRef = useRef({ client, enabled, streamIdentity }) + activeRouteRef.current = { client, enabled, streamIdentity } + useEffect(() => { + if (!enabled && timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, enabled, streamIdentity]) + return useCallback(() => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Stop not sent (terminal not ready)') + return + } + cancelPending() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const stopStreamIdentity = streamIdentity + let failureReported = false + const sendEscape = (): void => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.streamIdentity !== stopStreamIdentity || + handleRef.current !== handle + ) { + return + } + void client + .sendRequest('terminal.send', { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Why: disconnect can race either fire-and-forget Escape; surface one + // failure instead of leaking an unhandled RPC rejection. + if (!failureReported) { + failureReported = true + onSendError('Stop not sent') + } + }) + } + sendEscape() + // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. + timerRef.current = setTimeout(() => { + timerRef.current = null + sendEscape() + }, 80) + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) +} diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..92320b9ff86 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,115 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream' + +describe('useMobileNativeChatTerminalStream', () => { + let renderer: ReactTestRenderer | null = null + let harnessRenderCount = 0 + const subscriptionsRef = { current: new Map void>() } + const subscribingRef = { current: new Set() } + const webReadyRef = { current: new Set(['terminal-1']) } + const initializedRef = { current: new Set(['terminal-1']) } + const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) + const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + subscriptionsRef.current = new Map([['terminal-1', () => {}]]) + subscribingRef.current = new Set() + webReadyRef.current = new Set(['terminal-1']) + initializedRef.current = new Set(['terminal-1']) + harnessRenderCount = 0 + subscribe.mockClear() + unsubscribe.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + harnessRenderCount += 1 + notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle: 'terminal-1', + activeTabType: 'terminal', + subscriptionsRef, + subscribingRef, + webReadyRef, + initializedRef, + subscribe, + unsubscribe + }) + return null + } + + it('replaces output with a lease-only stream while covered, then restores output', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() + + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..c45980ae6e3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' + +/** Pauses the active terminal stream while native chat covers its mounted WebView, + * then resumes from a fresh scrollback snapshot when terminal view returns. */ +export function useMobileNativeChatTerminalStream(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + subscriptionsRef: MutableRefObject void>> + subscribingRef: MutableRefObject> + webReadyRef: MutableRefObject> + initializedRef: MutableRefObject> + subscribe: (handle: string) => void + unsubscribe: (handle: string) => void +}): (handle: string, wasAlreadyReady: boolean) => void { + const coveredHandleRef = useRef(null) + const [webReadyRevision, setWebReadyRevision] = useState(0) + const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + if (!wasAlreadyReady && coveredHandleRef.current === handle) { + setWebReadyRevision((revision) => revision + 1) + } + }, []) + useEffect(() => { + const handle = args.activeHandle + if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + coveredHandleRef.current = null + } + const streamActive = + handle != null && + (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const action = resolveMobileNativeChatTerminalStreamAction({ + showNativeChat: args.showNativeChat, + activeHandle: handle, + activeTabType: args.activeTabType, + streamActive, + streamCovered: coveredHandleRef.current === handle, + webViewReady: handle != null && args.webReadyRef.current.has(handle) + }) + if (!handle || action === 'none') { + return + } + if (action === 'pause') { + coveredHandleRef.current = handle + // Why: returning to terminal must accept the fresh scrollback snapshot; + // the stream was paused while chat covered output that xterm never saw. + args.initializedRef.current.delete(handle) + // Why: covered chat needs the input-floor lease without paying to stream + // duplicate PTY output. Replace any view stream with a lease-only stream. + if (streamActive) { + args.unsubscribe(handle) + } + args.subscribe(handle) + return + } + if (coveredHandleRef.current === handle) { + args.unsubscribe(handle) + coveredHandleRef.current = null + } + args.subscribe(handle) + }, [ + args.activeHandle, + args.activeTabType, + args.initializedRef, + args.showNativeChat, + args.subscribe, + args.subscribingRef, + args.subscriptionsRef, + args.unsubscribe, + args.webReadyRef, + webReadyRevision + ]) + return notifyWebReady +} diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts new file mode 100644 index 00000000000..bdea29c7459 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +describe('useThrottledLatestValue', () => { + let renderer: ReactTestRenderer | null = null + let latest: string | undefined + let consoleSpy: MockInstance + + function Harness({ value }: { value: string | undefined }): null { + latest = useThrottledLatestValue(value, 50) + return null + } + + function render(value: string | undefined): void { + act(() => { + renderer = create(createElement(Harness, { value })) + }) + } + + function update(value: string | undefined): void { + act(() => renderer?.update(createElement(Harness, { value }))) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + latest = undefined + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + consoleSpy.mockRestore() + }) + + it('emits the first frame immediately', () => { + render('a') + expect(latest).toBe('a') + }) + + it('holds rapid updates but eventually emits the latest value', () => { + render('a') + update('ab') + update('abc') + expect(latest).toBe('a') + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBe('abc') + }) + + it('clears immediately and drops the trailing emit when the value goes undefined', () => { + render('a') + update('ab') + expect(latest).toBe('a') + update(undefined) + expect(latest).toBeUndefined() + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-throttled-latest-value.ts b/mobile/src/session/use-throttled-latest-value.ts new file mode 100644 index 00000000000..652b1e79119 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' + +/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs` + * while always surfacing the latest value. OpenCode publishes a streaming + * assistant frame per part; unthrottled, each re-parses the whole bubble. */ +export function useThrottledLatestValue(value: T, intervalMs: number): T { + const [throttled, setThrottled] = useState(value) + const valueRef = useRef(value) + valueRef.current = value + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (value == null) { + // Turn ended: drop any trailing emit and reset so the next stream's first + // frame shows at once instead of the stale bubble lingering. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastEmitRef.current = 0 + setThrottled(value) + return + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= intervalMs) { + lastEmitRef.current = Date.now() + setThrottled(value) + return + } + if (timerRef.current) { + return + } + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = Date.now() + setThrottled(valueRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, + [] + ) + + return throttled +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index f8e07fb4d98..881391db0bd 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,6 +8,7 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -17,6 +18,7 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('native chat tab preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('loads and saves tab ids under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ + 'tab-1', + 'tab-2' + ]) + expect(AsyncStorage.getItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' + ) + + await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['tab-2']) + ) + }) +}) + describe('push notification preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..a5af9bf350f 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +/** Tab ids currently showing native chat, scoped to the paired host and worktree + * so colliding remote ids cannot activate a transcript watcher on another host. */ +export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { + try { + const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +export async function saveNativeChatTabIds( + hostId: string, + worktreeId: string, + ids: string[] +): Promise { + try { + await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) + } catch { + // Best-effort persistence; a failed write just forgets the view choice. + } +} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 76b14415a32..dbf09b745b9 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -18,6 +18,9 @@ export const colors = { surfaceBright: '#f5f5f5', accentBlue: '#3b82f6', + // Text/icon color on a filled accent (accentBlue) button, where the muted + // textPrimary would lack contrast against the saturated fill. + onAccent: '#ffffff', statusGreen: '#22c55e', statusAmber: '#f59e0b', diff --git a/mobile/src/transport/host-endpoint.test.ts b/mobile/src/transport/host-endpoint.test.ts new file mode 100644 index 00000000000..ee904278e98 --- /dev/null +++ b/mobile/src/transport/host-endpoint.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { + displayHostEndpoint, + endpointPort, + endpointScheme, + normalizeHostEndpoint +} from './host-endpoint' + +describe('displayHostEndpoint', () => { + it('shows host:port for websocket URLs', () => { + expect(displayHostEndpoint('ws://192.168.1.10:6768')).toBe('192.168.1.10:6768') + // Why: URL omits default scheme ports (443 for wss); use a non-default port. + expect(displayHostEndpoint('wss://desk.example:8443')).toBe('desk.example:8443') + }) + + it('returns the raw string when not a URL', () => { + expect(displayHostEndpoint('not-a-url')).toBe('not-a-url') + }) + + it('brackets IPv6 hostnames for round-trip safety', () => { + expect(displayHostEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('[fd7a:115c:a1e0::1]:6768') + }) +}) + +describe('endpointPort', () => { + it('reads the port when present', () => { + expect(endpointPort('ws://192.168.1.10:6768')).toBe('6768') + }) + + it('returns undefined when the port is omitted', () => { + expect(endpointPort('ws://192.168.1.10')).toBeUndefined() + }) + + it('preserves scheme-default ports that URL.port hides', () => { + expect(endpointPort('ws://192.168.1.10:80')).toBe('80') + expect(endpointPort('wss://desk.example:443')).toBe('443') + }) +}) + +describe('endpointScheme', () => { + it('returns wss for a wss:// endpoint', () => { + expect(endpointScheme('wss://desk.example:8443')).toBe('wss') + }) + + it('returns ws for a ws:// endpoint', () => { + expect(endpointScheme('ws://192.168.1.10:6768')).toBe('ws') + }) + + it('falls back to ws for a non-URL endpoint', () => { + expect(endpointScheme('not-a-url')).toBe('ws') + }) +}) + +describe('normalizeHostEndpoint', () => { + it('accepts a full ws URL', () => { + expect(normalizeHostEndpoint('ws://100.64.0.5:6768')).toEqual({ + ok: true, + endpoint: 'ws://100.64.0.5:6768' + }) + }) + + it('accepts wss and preserves scheme', () => { + expect(normalizeHostEndpoint('wss://desk.example:8443')).toEqual({ + ok: true, + endpoint: 'wss://desk.example:8443' + }) + }) + + it('accepts bare host:port', () => { + expect(normalizeHostEndpoint('192.168.1.10:6768')).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:6768' + }) + }) + + it('defaults missing port to 6768', () => { + expect(normalizeHostEndpoint('192.168.1.10')).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:6768' + }) + }) + + it('uses fallbackPort when the user omits the port', () => { + expect(normalizeHostEndpoint('mac-mini.local', { fallbackPort: '7777' })).toEqual({ + ok: true, + endpoint: 'ws://mac-mini.local:7777' + }) + }) + + it('fills missing port on scheme URLs from fallbackPort', () => { + expect(normalizeHostEndpoint('ws://192.168.1.10', { fallbackPort: '9000' })).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:9000' + }) + }) + + it('preserves explicit ws :80 and wss :443 instead of rewriting to fallback', () => { + expect(normalizeHostEndpoint('ws://192.168.1.10:80', { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:80' + }) + expect(normalizeHostEndpoint('wss://desk.example:443', { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: 'wss://desk.example:443' + }) + expect(displayHostEndpoint('ws://192.168.1.10:80')).toBe('192.168.1.10:80') + expect(displayHostEndpoint('wss://desk.example:443')).toBe('desk.example:443') + }) + + it('trims whitespace', () => { + expect(normalizeHostEndpoint(' 10.0.0.2:6768 ')).toEqual({ + ok: true, + endpoint: 'ws://10.0.0.2:6768' + }) + }) + + it('rejects empty input', () => { + expect(normalizeHostEndpoint(' ')).toEqual({ + ok: false, + error: 'Enter a host address.' + }) + }) + + it('rejects non-websocket schemes', () => { + expect(normalizeHostEndpoint('http://192.168.1.10:6768')).toEqual({ + ok: false, + error: 'Use ws:// or wss:// (or host:port).' + }) + }) + + it('rejects explicit invalid ports without falling back', () => { + for (const input of [ + '192.168.1.10:0', + '192.168.1.10:99999', + 'ws://192.168.1.10:0', + 'ws://192.168.1.10:99999' + ]) { + expect(normalizeHostEndpoint(input, { fallbackPort: '6768' })).toEqual({ + ok: false, + error: 'Port must be 1–65535.' + }) + } + }) + + it('rejects bare hosts with path, query, or spaces', () => { + expect(normalizeHostEndpoint('desk/path')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + expect(normalizeHostEndpoint('desk?route')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + expect(normalizeHostEndpoint('desk name')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + }) + + it('rejects scheme URLs with path or query', () => { + expect(normalizeHostEndpoint('ws://desk.example/path')).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + expect(normalizeHostEndpoint('ws://desk.example?route=1')).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + }) + + it('accepts bracketed IPv6 with port', () => { + expect(normalizeHostEndpoint('[fd7a:115c:a1e0::1]:6768')).toEqual({ + ok: true, + endpoint: 'ws://[fd7a:115c:a1e0::1]:6768' + }) + }) + + it('rejects malformed IPv6 that a WebSocket URL cannot parse', () => { + expect(normalizeHostEndpoint('[1::2::3]:6768')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + }) + + it('rejects non-canonical numeric IPv4 forms in bare addresses', () => { + for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) { + expect(normalizeHostEndpoint(`${host}:6768`)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects non-canonical numeric IPv4 forms before URL normalization', () => { + for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) { + expect(normalizeHostEndpoint(`ws://${host}:6768`)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects encoded and trailing-dot numeric IPv4 aliases', () => { + for (const input of ['ws://127.1.:6768', 'wss://127.1.:6768', 'ws://%31%32%37.1:6768']) { + expect(normalizeHostEndpoint(input)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects empty userinfo before URL normalization', () => { + expect(normalizeHostEndpoint('ws://@127.1:6768')).toEqual({ + ok: false, + error: 'Not a valid address.' + }) + }) + + it('reports a path or query before validating a numeric hostname alias', () => { + for (const input of ['ws://127.1/path', 'ws://127.1?route=1']) { + expect(normalizeHostEndpoint(input)).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + } + }) + + it('keeps ordinary DNS names that contain numeric labels', () => { + expect(normalizeHostEndpoint('desk123.local:6768')).toEqual({ + ok: true, + endpoint: 'ws://desk123.local:6768' + }) + expect(normalizeHostEndpoint('ws://123.example:6768')).toEqual({ + ok: true, + endpoint: 'ws://123.example:6768' + }) + }) + + it('preserves wss when re-normalizing bare host:port via fallbackScheme', () => { + expect( + normalizeHostEndpoint('desk.example:8443', { fallbackScheme: 'wss', fallbackPort: '8443' }) + ).toEqual({ + ok: true, + endpoint: 'wss://desk.example:8443' + }) + }) + + it('round-trips displayed IPv6 endpoints without port corruption', () => { + const original = 'ws://[fd7a:115c:a1e0::1]:6768' + const displayed = displayHostEndpoint(original) + expect(displayed).toBe('[fd7a:115c:a1e0::1]:6768') + expect(normalizeHostEndpoint(displayed, { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: original + }) + }) +}) diff --git a/mobile/src/transport/host-endpoint.ts b/mobile/src/transport/host-endpoint.ts new file mode 100644 index 00000000000..68d77aa4b1a --- /dev/null +++ b/mobile/src/transport/host-endpoint.ts @@ -0,0 +1,365 @@ +// Why: mobile host profiles store a single websocket endpoint fixed at pair +// time. Edit-host lets the user rewrite host/port without re-pairing; this +// helper accepts phone-friendly input (bare IP, host:port, or full ws URL) +// and normalizes to the ws(s):// form RpcClient expects. + +export type NormalizeHostEndpointResult = + | { ok: true; endpoint: string } + | { ok: false; error: string } + +type WebsocketUrlPortResolution = + | { kind: 'missing' } + | { kind: 'valid'; port: string } + | { kind: 'invalid' } + +type RawSchemeAuthority = { + hostname: string | null + hasUserInfo: boolean + hasPathOrQuery: boolean +} + +const DEFAULT_PORT = '6768' +const NUMERIC_IPV4_CANDIDATE = /^(?:0[xX][0-9a-fA-F]+|\d+)(?:\.(?:0[xX][0-9a-fA-F]+|\d+))*$/ + +export function displayHostEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + // Why: some URL parsers leave IPv6 brackets on hostname, others strip them. + // Normalize once so round-trip through normalizeHostEndpoint stays stable. + const host = formatHostForUrl(unwrapHostname(url.hostname)) + const port = resolveWebsocketUrlPort(endpoint, url) + if (port.kind === 'invalid') { + return endpoint + } + return port.kind === 'valid' ? `${host}:${port.port}` : host + } catch { + return endpoint + } +} + +function unwrapHostname(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname +} + +/** + * Recover an explicitly written port from a ws(s) URL authority. + * Why: `new URL('ws://host:80').port` and `wss://host:443` are empty — the + * URL parser hides scheme-default ports, so callers that need the user's + * literal :80/:443 must re-parse the original string. + */ +function extractExplicitPortFromWebsocketUrl(input: string): string | null { + const withoutScheme = input.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '') + if (withoutScheme.startsWith('[')) { + const close = withoutScheme.indexOf(']') + if (close <= 1) { + return null + } + const rest = withoutScheme.slice(close + 1) + const match = /^:(\d+)(?=[/?#]|$)/.exec(rest) + return match?.[1] ?? null + } + const end = withoutScheme.search(/[/?#]/) + const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end) + const at = authority.lastIndexOf('@') + const hostPort = at === -1 ? authority : authority.slice(at + 1) + const match = /:(\d+)$/.exec(hostPort) + return match?.[1] ?? null +} + +function resolveWebsocketUrlPort(input: string, url?: URL): WebsocketUrlPortResolution { + const explicit = extractExplicitPortFromWebsocketUrl(input) + // Why: missing and invalid are different states. Treating both as null lets + // an explicit :0/:99999 silently inherit fallbackPort on permissive parsers. + if (explicit !== null && !isValidPort(explicit)) { + return { kind: 'invalid' } + } + if (url?.port) { + return isValidPort(url.port) ? { kind: 'valid', port: url.port } : { kind: 'invalid' } + } + if (explicit !== null) { + return { kind: 'valid', port: explicit } + } + return { kind: 'missing' } +} + +export function endpointPort(endpoint: string): string | undefined { + try { + const url = new URL(endpoint) + const port = resolveWebsocketUrlPort(endpoint, url) + return port.kind === 'valid' ? port.port : undefined + } catch { + return undefined + } +} + +export function endpointScheme(endpoint: string): 'ws' | 'wss' { + try { + const protocol = new URL(endpoint).protocol.replace(':', '') + return protocol === 'wss' ? 'wss' : 'ws' + } catch { + return 'ws' + } +} + +export function normalizeHostEndpoint( + input: string, + options?: { fallbackPort?: string | number; fallbackScheme?: 'ws' | 'wss' } +): NormalizeHostEndpointResult { + const trimmed = input.trim() + if (!trimmed) { + return { ok: false, error: 'Enter a host address.' } + } + + const fallbackPort = resolveFallbackPort(options?.fallbackPort) + const fallbackScheme = options?.fallbackScheme === 'wss' ? 'wss' : 'ws' + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + return normalizeSchemeUrl(trimmed, fallbackPort) + } + + return normalizeHostPort(trimmed, fallbackPort, fallbackScheme) +} + +function resolveFallbackPort(value: string | number | undefined): string { + if (value == null) { + return DEFAULT_PORT + } + const asString = String(value).trim() + if (!asString || !isValidPort(asString)) { + return DEFAULT_PORT + } + return asString +} + +function normalizeSchemeUrl(input: string, fallbackPort: string): NormalizeHostEndpointResult { + const explicitPort = resolveWebsocketUrlPort(input) + if (explicitPort.kind === 'invalid') { + return { ok: false, error: 'Port must be 1–65535.' } + } + const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(input)?.[1]?.toLowerCase() + if (scheme !== 'ws' && scheme !== 'wss') { + return { ok: false, error: 'Use ws:// or wss:// (or host:port).' } + } + + const rawAuthority = parseRawSchemeAuthority(input) + if (rawAuthority.hasUserInfo) { + return { ok: false, error: 'Not a valid address.' } + } + if (rawAuthority.hasPathOrQuery) { + return { ok: false, error: 'Host must not include a path or query.' } + } + if ( + rawAuthority.hostname && + validateNumericIpv4Candidate(normalizeRawNumericIpv4Candidate(rawAuthority.hostname)) + ) { + return { ok: false, error: 'Not a valid hostname.' } + } + + let url: URL + try { + url = new URL(input) + } catch { + return { ok: false, error: 'Not a valid address.' } + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + return { ok: false, error: 'Use ws:// or wss:// (or host:port).' } + } + if (!url.hostname) { + return { ok: false, error: 'Missing hostname.' } + } + + // Why: edit-host persists a bare host:port WebSocket endpoint. Path/query/ + // userinfo are not part of the pairing contract — reject rather than strip + // so typos like desk/path or desk?route cannot be saved silently. + if (url.username || url.password) { + return { ok: false, error: 'Not a valid address.' } + } + if ((url.pathname && url.pathname !== '/') || url.search || url.hash) { + return { ok: false, error: 'Host must not include a path or query.' } + } + + const hostname = unwrapHostname(url.hostname) + // Why: WHATWG URL accepts legacy aliases and rewrites them to a different + // IPv4 address. Only an already-canonical raw dotted quad may become IPv4. + if (rawAuthority.hostname && isCanonicalIpv4(hostname) && rawAuthority.hostname !== hostname) { + return { ok: false, error: 'Not a valid hostname.' } + } + const hostError = validateHostname(hostname) + if (hostError) { + return { ok: false, error: hostError } + } + + // Why: keep explicit :80/:443 (URL.port is empty for scheme defaults) instead + // of rewriting them to fallbackPort (usually 6768). + const resolvedPort = resolveWebsocketUrlPort(input, url) + if (resolvedPort.kind === 'invalid') { + return { ok: false, error: 'Port must be 1–65535.' } + } + const port = resolvedPort.kind === 'valid' ? resolvedPort.port : fallbackPort + + // Why: rebuild so accidental whitespace never reaches the WebSocket constructor. + return { ok: true, endpoint: `${url.protocol}//${formatHostForUrl(hostname)}:${port}` } +} + +function parseRawSchemeAuthority(input: string): RawSchemeAuthority { + const schemeEnd = input.indexOf('://') + const remainder = input.slice(schemeEnd + 3) + const authorityEnd = remainder.search(/[/?#]/) + const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd) + const suffix = authorityEnd === -1 ? '' : remainder.slice(authorityEnd) + const hasUserInfo = authority.includes('@') + const hostPort = hasUserInfo ? authority.slice(authority.lastIndexOf('@') + 1) : authority + if (!hostPort) { + return { hostname: null, hasUserInfo, hasPathOrQuery: suffix !== '' && suffix !== '/' } + } + if (hostPort.startsWith('[')) { + const close = hostPort.indexOf(']') + return { + hostname: close > 1 ? hostPort.slice(1, close) : null, + hasUserInfo, + hasPathOrQuery: suffix !== '' && suffix !== '/' + } + } + const lastColon = hostPort.lastIndexOf(':') + return { + hostname: lastColon === -1 ? hostPort : hostPort.slice(0, lastColon), + hasUserInfo, + hasPathOrQuery: suffix !== '' && suffix !== '/' + } +} + +function normalizeHostPort( + input: string, + fallbackPort: string, + fallbackScheme: 'ws' | 'wss' +): NormalizeHostEndpointResult { + let host: string + let port: string | undefined + + if (input.startsWith('[')) { + const close = input.indexOf(']') + if (close <= 1) { + return { ok: false, error: 'Not a valid address.' } + } + host = input.slice(1, close) + const rest = input.slice(close + 1) + if (rest.startsWith(':')) { + port = rest.slice(1) + } else if (rest.length > 0) { + return { ok: false, error: 'Not a valid address.' } + } + } else { + const firstColon = input.indexOf(':') + const lastColon = input.lastIndexOf(':') + if (firstColon !== -1 && firstColon === lastColon) { + host = input.slice(0, firstColon) + port = input.slice(firstColon + 1) + } else { + // No port, or bare IPv6 (multiple colons, no brackets). + host = input + } + } + + host = host.trim() + if (!host) { + return { ok: false, error: 'Missing hostname.' } + } + + // Why: bare input is not a URL, so characters that only make sense in a URL + // (path, query, fragment, whitespace) must not be treated as hostname bytes. + const hostError = validateHostname(host) + if (hostError) { + return { ok: false, error: hostError } + } + + if (port !== undefined) { + port = port.trim() + if (!isValidPort(port)) { + return { ok: false, error: 'Port must be 1–65535.' } + } + } + + const finalPort = port ?? fallbackPort + return { ok: true, endpoint: `${fallbackScheme}://${formatHostForUrl(host)}:${finalPort}` } +} + +function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host +} + +/** + * Reject hostnames that would be illegal or ambiguous in a websocket URL. + * Allows DNS labels, `.local` mDNS, IPv4, and IPv6 hex forms. + */ +function validateHostname(host: string): string | null { + if (!host) { + return 'Missing hostname.' + } + // Spaces, path/query/fragment separators, userinfo separators, brackets. + if (/[\s/?#@[\]]/.test(host)) { + return 'Not a valid hostname.' + } + const numericIpv4Error = validateNumericIpv4Candidate(host) + if (numericIpv4Error) { + return numericIpv4Error + } + if (host.includes(':')) { + // Why: a hex/colon regex accepts malformed forms such as two `::` runs. + // Reuse the URL parser that WebSocket will ultimately use. + if (!/^[0-9a-fA-F:]+$/.test(host)) { + return 'Not a valid hostname.' + } + try { + new URL(`ws://[${host}]:${DEFAULT_PORT}`) + } catch { + return 'Not a valid hostname.' + } + return null + } + // DNS / IPv4 / mDNS: labels of alnum and hyphen, dots between, no empty labels. + if ( + !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( + host + ) + ) { + return 'Not a valid hostname.' + } + return null +} + +function validateNumericIpv4Candidate(host: string): string | null { + if (!NUMERIC_IPV4_CANDIDATE.test(host)) { + return null + } + if (!isCanonicalIpv4(host)) { + return 'Not a valid hostname.' + } + return null +} + +function normalizeRawNumericIpv4Candidate(host: string): string { + let decoded = host + try { + decoded = decodeURIComponent(host) + } catch { + // The URL parser will reject malformed escapes; keep them untouched here. + } + return decoded.endsWith('.') ? decoded.slice(0, -1) : decoded +} + +function isCanonicalIpv4(host: string): boolean { + const octets = host.split('.') + return ( + octets.length === 4 && + octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255) + ) +} + +function isValidPort(port: string): boolean { + if (!/^\d+$/.test(port)) { + return false + } + const n = Number(port) + return n >= 1 && n <= 65535 +} diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts new file mode 100644 index 00000000000..95d9c364b88 --- /dev/null +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -0,0 +1,95 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { updateHostNameAndEndpoint } from './host-store' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() + } +})) + +vi.mock('expo-secure-store', () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})) + +describe('updateHostNameAndEndpoint', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + const stored = [ + { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://100.64.0.5:6768', + publicKeyB64: 'pk', + lastConnected: 1 + }, + { + id: 'host-2', + name: 'Laptop', + endpoint: 'wss://laptop.example:8443', + publicKeyB64: 'pk-2', + lastConnected: 2 + } + ] + + it('commits name and endpoint together in a single write', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { + name: 'Home Desk', + endpoint: 'ws://192.168.1.10:6768' + }) + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([ + { ...stored[0], name: 'Home Desk', endpoint: 'ws://192.168.1.10:6768' }, + stored[1] + ]) + ) + }) + + it('updates only the provided field', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { name: 'Home Desk' }) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([{ ...stored[0], name: 'Home Desk' }, stored[1]]) + ) + }) + + it('rewrites only the endpoint when name is omitted', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' }) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]]) + ) + }) + + it('throws and writes nothing when the host is missing', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('[]') + + await expect(updateHostNameAndEndpoint('missing', { name: 'Renamed' })).rejects.toThrow( + 'Host not found' + ) + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index a1aa3d677a1..918e6e92f93 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -37,9 +37,11 @@ import { loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, - renameHost, + resolvePairingHostIdentity, resetHostStoreForTests, + saveHost, saveExistingHostRelayUpgrade, + updateHostNameAndEndpoint, updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' @@ -85,6 +87,8 @@ describe('host-store list mutations', () => { asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { if (key === HOSTS_STORAGE_KEY) { storedHostsRaw = raw + } else if (key === OVERLAY_STORAGE_KEY) { + storedOverlayRaw = raw } }) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => @@ -92,6 +96,89 @@ describe('host-store list mutations', () => { ) }) + it('resolves an existing host by pinned key with one durable read', async () => { + await expect(resolvePairingHostIdentity(HOST_TWO.publicKeyB64, 'host-new')).resolves.toEqual({ + id: HOST_TWO.id, + name: HOST_TWO.name + }) + expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() + }) + + it('names a new host from the same durable read used for identity lookup', async () => { + await expect(resolvePairingHostIdentity('unpaired-key', 'host-new')).resolves.toEqual({ + id: 'host-new', + name: 'Host 3' + }) + expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() + }) + + it('fails closed when durable host identity storage is unreadable', async () => { + asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) + + await expect(resolvePairingHostIdentity('key-new', 'host-new')).rejects.toThrow(/unreadable/) + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + }) + + it('collapses already-persisted duplicates when the desktop is re-paired', async () => { + storedHostsRaw = JSON.stringify([ + HOST_ONE, + { ...HOST_TWO, id: 'host-duplicate', publicKeyB64: HOST_ONE.publicKeyB64 } + ]) + + await saveHost({ + ...HOST_ONE, + endpoint: 'ws://127.0.0.1:3', + deviceToken: 'replacement-token' + }) + + expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) + }) + + it('clears stale relay state when an existing host is re-paired direct-only', async () => { + const overlay: MobileRelayHostOverlay = { + v: 2, + hostId: HOST_ONE.id, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } + } + storedOverlayRaw = JSON.stringify([overlay]) + + await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + + expect(JSON.parse(storedOverlayRaw!)).toEqual([]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not touch relay storage when saving a new direct-only host', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(asyncStorageMock.getItem).not.toHaveBeenCalledWith(OVERLAY_STORAGE_KEY) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -186,7 +273,7 @@ describe('host-store list mutations', () => { return storedHostsRaw }) - const rename = renameHost(HOST_ONE.id, 'Renamed Host') + const rename = updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Renamed Host' }) const remove = removeHost(HOST_TWO.id) // Both writers have started their RMW and are blocked on the shared read // gate; without a mutation queue the second would clobber the first. @@ -207,9 +294,9 @@ describe('host-store list mutations', () => { it('preserves a rename when lastConnected updates race it', async () => { const before = Date.now() await Promise.all([ - renameHost(HOST_ONE.id, 'Alpha'), + updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Alpha' }), updateLastConnected(HOST_ONE.id), - renameHost(HOST_TWO.id, 'Beta') + updateHostNameAndEndpoint(HOST_TWO.id, { name: 'Beta' }) ]) const stored = JSON.parse(storedHostsRaw) as Array @@ -225,7 +312,9 @@ describe('host-store list mutations', () => { it('does not wipe the host list when storage is unreadable during mutation', async () => { storedHostsRaw = '{' - await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/) + await expect(updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Nope' })).rejects.toThrow( + /unreadable/ + ) expect(asyncStorageMock.setItem).not.toHaveBeenCalled() expect(storedHostsRaw).toBe('{') }) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index a17e6f57a1b..a404379e557 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -15,6 +15,7 @@ import { import { loadMobileRelayHostOverlayState, removeMobileRelayHostOverlay, + removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' @@ -182,12 +183,18 @@ async function doLoadHosts(): Promise { return out } -async function loadStoredHosts(): Promise { - try { - return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? [] - } catch { - return [] - } +export async function resolvePairingHostIdentity( + publicKeyB64: string, + newHostId: string +): Promise<{ id: string; name: string }> { + // Why: one durable read both preserves an existing identity and names a new host, + // avoiding duplicate cards and a second serial storage read before connecting. + await hostListMutation + const hosts = await readStoredHostsForMutation() + const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) + return match + ? { id: match.id, name: match.name } + : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } async function readStoredHostsForMutation(): Promise { @@ -242,18 +249,28 @@ export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { const validated = HostProfileSchema.parse(host) const stored = toStored(validated) + const duplicateHostIds = new Set() + let updatedExistingHost = false await mutateStoredHosts((hosts) => { const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } if (index >= 0) { - const next = hosts.slice() - next[index] = stored - return next + updatedExistingHost = true + // Why: affected installs may already contain duplicate rows; an authoritative + // save is the safe point to collapse them to the preserved host id. + return hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) } if (requireExisting) { // Why: an in-flight relay upgrade must not resurrect a host the user removed. throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') } - return [...hosts, stored] + return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] }) // Why: write metadata BEFORE the keychain token so a crash between the two // leaves orphaned metadata (which loadHosts skips and removeHost can clean @@ -271,6 +288,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise relay: validated.relay }) } + const overlayRemovalIds = [...duplicateHostIds] + if (!validated.endpoints && updatedExistingHost) { + overlayRemovalIds.push(stored.id) + } + if (overlayRemovalIds.length > 0) { + // Why: reusing an id for direct-only re-pairing must not retain routing + // metadata from the host's previous transport state. + await removeMobileRelayHostOverlays(overlayRemovalIds) + } + for (const duplicateHostId of duplicateHostIds) { + tokenCache.delete(duplicateHostId) + try { + await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + } catch { + // Metadata is already deduplicated; orphan-token recovery is best-effort. + } + } } export async function removeHost(hostId: string): Promise { @@ -299,24 +333,29 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ return retryPendingHostCredentialCleanups(deleteHostCredentials) } -export async function renameHost(hostId: string, newName: string): Promise { +// Why: Edit host can change name and endpoint together; a single +// mutateStoredHosts pass keeps both fields committed atomically so a +// mid-save failure can never persist one change without the other, and a +// host removed mid-edit throws consistently instead of silently no-oping. +export async function updateHostNameAndEndpoint( + hostId: string, + updates: { name?: string; endpoint?: string } +): Promise { await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === hostId) + const index = hosts.findIndex((host) => host.id === hostId) if (index < 0) { - return hosts + throw new Error('Host not found') } const next = hosts.slice() - next[index] = { ...next[index]!, name: newName } + next[index] = { + ...next[index]!, + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.endpoint !== undefined ? { endpoint: updates.endpoint } : {}) + } return next }) } -export async function getNextHostName(): Promise { - await hostListMutation - const hosts = await loadStoredHosts() - return getNextHostNameFromHosts(hosts) -} - export async function updateLastConnected(hostId: string): Promise { try { await mutateStoredHosts((hosts) => { diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 8286fe67f5b..812d61ebb1d 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -9,6 +9,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStor import { loadMobileRelayHostOverlays, + removeMobileRelayHostOverlays, resetMobileRelayHostOverlayStoreForTests, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' @@ -77,4 +78,24 @@ describe('mobile relay host overlay store', () => { await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + + it('removes requested overlays in one storage write', async () => { + const second = { ...OVERLAY, hostId: 'host-2' } + stored = JSON.stringify([OVERLAY, second]) + + await expect(removeMobileRelayHostOverlays(['host-1', 'host-missing'])).resolves.toBeUndefined() + + expect(JSON.parse(stored!)).toEqual([second]) + expect(asyncStorage.getItem).toHaveBeenCalledOnce() + expect(asyncStorage.setItem).toHaveBeenCalledOnce() + }) + + it('skips the storage write when no requested overlay exists', async () => { + stored = JSON.stringify([OVERLAY]) + + await expect(removeMobileRelayHostOverlays(['host-missing'])).resolves.toBeUndefined() + + expect(asyncStorage.getItem).toHaveBeenCalledOnce() + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 4dbb50d9f46..7f1519eb5c9 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -40,7 +40,12 @@ async function mutateOverlays( ): Promise { const mutation = overlayMutation.then(async () => { const current = await readOverlaysForMutation() - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current))) + const next = update(current) + // Why: direct-only saves commonly have no overlay to remove; avoid a full + // AsyncStorage write when cleanup leaves the durable list unchanged. + if (next !== current) { + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + } }) overlayMutation = mutation.catch(() => {}) return mutation @@ -85,7 +90,22 @@ export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay } export function removeMobileRelayHostOverlay(hostId: string): Promise { - return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId)) + return removeMobileRelayHostOverlays([hostId]) +} + +export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise { + const targets = new Set(hostIds) + let removed = false + return mutateOverlays((overlays) => { + const next = overlays.filter((overlay) => { + if (!targets.has(overlay.hostId)) { + return true + } + removed = true + return false + }) + return removed ? next : overlays + }) } /** Test-only: drain the module mutation chain between cases. */ diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 16c833bf4f2..782c02aed50 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -64,7 +64,10 @@ function dependencies(client: RpcClient, events: string[]) { resolveInviteDirector: vi.fn(async () => { throw new Error('director unavailable') }), - getNextHostName: vi.fn(async () => 'Blue Whale'), + resolveHostIdentity: vi.fn(async (_publicKeyB64: string, hostId: string) => ({ + id: hostId, + name: 'Blue Whale' + })), saveHost: vi.fn(async (_host: HostProfile) => { events.push('save-host') }), @@ -136,6 +139,35 @@ describe('pre-profile pairing coordinator', () => { expect(events).toEqual(['connect', 'save-host']) }) + it('reuses the existing host id and name when re-pairing the same desktop key (no duplicate)', async () => { + // STA-1840: re-pairing a desktop already stored under a different id must + // merge into that card, not mint a new host-${now} and duplicate the row. + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' })]) + const deps = dependencies(client, events) + deps.resolveHostIdentity = vi.fn(async (publicKeyB64: string, newHostId: string) => { + expect(publicKeyB64).toBe(directOffer.publicKeyB64) + expect(newHostId).toBe(`host-${now}`) + return { id: 'host-existing', name: 'Studio Mac' } + }) + + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + + await expect(attempt.result).resolves.toEqual({ hostId: 'host-existing' }) + expect(deps.saveHost).toHaveBeenCalledWith({ + id: 'host-existing', + name: 'Studio Mac', + endpoint: directOffer.endpoint, + deviceToken: directOffer.deviceToken, + publicKeyB64: directOffer.publicKeyB64, + lastConnected: now + }) + }) + it('journals before connecting and publishes only after authoritative direct install', async () => { const events: string[] = [] let journal: MobileRelayPairingJournal | null = null diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 9d2a3b48ddb..82bd989be03 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -6,7 +6,7 @@ import { type MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { connect, type ConnectOptions } from './rpc-client' -import { getNextHostName, saveHost } from './host-store' +import { resolvePairingHostIdentity, saveHost } from './host-store' import type { HostProfile, PairingOffer, RpcResponse } from './types' import { createMobileRelayPairingJournal, @@ -39,7 +39,7 @@ type Dependencies = { connectDirect: typeof connect connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector - getNextHostName: typeof getNextHostName + resolveHostIdentity: typeof resolvePairingHostIdentity saveHost: typeof saveHost saveJournal: typeof saveMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal @@ -53,7 +53,7 @@ const defaultDependencies: Dependencies = { connectDirect: connect, connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, - getNextHostName, + resolveHostIdentity: resolvePairingHostIdentity, saveHost, saveJournal: saveMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, @@ -130,8 +130,12 @@ async function runPairing( isDisposed: () => boolean ): Promise<{ hostId: string }> { const now = dependencies.now() - const hostId = `host-${now}` - const hostName = await dependencies.getNextHostName() + // Why: every pairing artifact must share the preserved host id so re-pairing + // updates one card instead of publishing a second identity (STA-1840). + const { id: hostId, name: hostName } = await dependencies.resolveHostIdentity( + offer.publicKeyB64, + `host-${now}` + ) assertActive(isDisposed) let journal: MobileRelayPairingJournal | null = null if (offer.relay && dependencies.platform !== 'web') { diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 9afc4a1fd0c..471bc3c4a4e 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -1,3 +1,5 @@ +import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe' + type TerminalStreamParams = { terminal?: unknown } @@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport( } } +/** Build the unsubscribe RPC for a streaming method that needs the host told to + * tear down (session tabs, native chat), or null when none is required. Keeps + * the per-method echo logic out of the rpc-client teardown closure. */ +export function buildStreamUnsubscribe( + method: string | undefined, + params: unknown +): { method: string; params: Record } | null { + if (!params || typeof params !== 'object') { + return null + } + if (method === 'session.tabs.subscribe') { + const worktree = (params as { worktree?: unknown }).worktree + return typeof worktree === 'string' + ? { method: 'session.tabs.unsubscribe', params: { worktree } } + : null + } + if (method === 'nativeChat.subscribe') { + const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId + if (typeof subscriptionId === 'string') { + return { method: 'nativeChat.unsubscribe', params: { subscriptionId } } + } + // Backward compatibility for callers that predate explicit cleanup tokens. + const agent = (params as { agent?: unknown }).agent + const sessionId = (params as { sessionId?: unknown }).sessionId + return typeof agent === 'string' && typeof sessionId === 'string' + ? buildNativeChatUnsubscribe(agent, sessionId) + : null + } + return null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 014bea955cd..5420ba679a6 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -23,6 +23,7 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' @@ -1171,18 +1172,11 @@ export function connect( params: unsubscribeParams }) } - } else if ( - stream?.method === 'session.tabs.subscribe' && - stream.params && - typeof stream.params === 'object' && - typeof (stream.params as { worktree?: unknown }).worktree === 'string' - ) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'session.tabs.unsubscribe', - params: { worktree: (stream.params as { worktree: string }).worktree } - }) + } else { + const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsub) { + sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) + } } removeStreamListener(id) } diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md index 5da3b242817..8d12f8915a7 100644 --- a/notes/skill-freshness-design.md +++ b/notes/skill-freshness-design.md @@ -126,6 +126,7 @@ Rules: ### C. Read-only detection (kept from Phase 1, slimmed) Kept as-is: + - Bundled `skills/` packages + current manifest + released-snapshot registry + release mapping, with the generation script and merge-queue monotonicity gate (static data + CI, not runtime machinery). @@ -135,9 +136,12 @@ Kept as-is: plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. - The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests to historical-fat-install → targeted global update → stub migration. The matrix covers - copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity. + LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy + observation that accepts only unchanged historical or exact-current bytes. Post-update + bytes, not exit status, decide. Slimmed: + - Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly distinguish a locally modified official copy from unrelated same-named content; @@ -151,32 +155,51 @@ Slimmed: ### D. Surfacing -- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. -- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected - skill name, so eligibility is computed across all discovered placements of that name, not - per row. Offer a name only when at least one placement is `outdated` and every placement is - an exact `current` or `outdated` official snapshot in a supported global topology. One - `newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported - provider copy poisons the update offer for that name entirely. +- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the + existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace + Environments. Their installed pills carry safe freshness status, while their existing + Update and Re-check actions remain the per-skill path. The Skills page was + de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — + so it is no longer a venue; the freshness surface moved off it entirely. The behavior + contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory + triggers) are unchanged; only the venue moved. +- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, + `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in + the modal's collapsed **Details** section (auto-expanded when a placement is blocked). +- **Name-scoped update eligibility:** eligibility is computed across all discovered placements + of a name, not per row. Offer a name only when at least one placement is `outdated` and every + placement is an exact `current` or `outdated` official snapshot in a topology the validated + rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and + provider aliases to it. Independent provider copies are informational and poison the offer: + empirical copy-mode testing produced both stale and converged provider copies in otherwise + equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One + `newer-known`, unrecognized, external, read-only, + inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the + update offer for that name entirely. - The action combines only eligible outdated Orca names into - `npx skills update --global`, opens the existing run-command terminal with that - command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit or focus; only observed - bytes, not the skills CLI exit status, determine success. -- **One non-repeating nudge**: count only eligible outdated skill names and offer the same - targeted run-the-command action. An outdated name poisoned by another placement remains - visible in settings but never produces an unsafe nudge action. Dismissal is recorded per - (install, bundled revision), so a newly outdated official placement or genuinely newer - stub revision may prompt once more. No toggle — nothing automatic happens that would need - one. + `npx skills update --global` and opens the update modal's editable terminal with + that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and + never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only + observed bytes, not the skills CLI exit status, determine success. When the eligible set + empties and every placement is `current`, the modal shows an up-to-date state; if placements + remain outdated-but-blocked or unrecognized, it says so honestly instead. +- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the + same targeted action, which opens the update modal. The toast lingers (no auto-close) until the + user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a + still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks + the offered tuple retracts the stale toast without recording a dismissal. An outdated name + poisoned by another placement remains visible in the modal's Details but never produces an + unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only + on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision + may prompt once more. No + toggle — nothing automatic happens that would need one. ### E. Migration (fat → stub) 1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and settings/nudge UI, including the +2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the name-scoped targeted update action and the real migration-rail CI. Keep distributed skills fat. 3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout @@ -211,15 +234,17 @@ not thin, until the relevant variant passes. global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a - validated CLI-version policy before rollout; monitor and contribute upstream fixes. + failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is + 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence + still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream + fixes before broadening eligibility. - **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing installs only when users run the npx command. Acceptable at stub-change cadence; the compiled guides (the content that matters) are exempt by construction. - **Multi-file skills.** Current shipped packages are single-file. If a future skill needs scripts/assets, either the binary serves them (`--full` / `--script`) or that skill accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue: +- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command surface that will handle subsequent requests. Remote stub installs can lag on trigger copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are diff --git a/package.json b/package.json index efcbf53ac08..292873687f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.142-rc.6", + "version": "1.4.144-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", + "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", @@ -131,6 +133,7 @@ "@electron/rebuild": "^4.2.0", "@monaco-editor/react": "^4.7.0", "@playwright/test": "^1.59.1", + "@sanity/diff-match-patch": "^3.2.0", "@stablyai/playwright-test": "^2.1.14", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-virtual": "^3.13.24", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7af13f9c378..b8d00d79dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 '@stablyai/playwright-test': specifier: ^2.1.14 version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) @@ -2604,6 +2607,10 @@ packages: cpu: [x64] os: [win32] + '@sanity/diff-match-patch@3.2.0': + resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + engines: {node: '>=18.18'} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -8799,6 +8806,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@sanity/diff-match-patch@3.2.0': {} + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/is@4.6.0': {} diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md deleted file mode 100644 index 64cbb3d93ce..00000000000 --- a/pty-exhaustion-agent-session-leak.md +++ /dev/null @@ -1,178 +0,0 @@ -# Design Doc: PTY Exhaustion from Leaked Agent Sessions - -**Date:** 2026-07-13 -**Status:** Root cause identified; fixes proposed -**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). -**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. - ---- - -## 1. Summary - -An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. - -The root cause is **two independent defects that compound into a self-perpetuating leak**: - -- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. -- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. - -Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. - ---- - -## 2. Observed incident (live-machine evidence) - -Measurements taken 2026-07-13 on the affected desktop: - -| Metric | Value | -| ------------------------------------------------- | ----------------------------------------------------------------------- | -| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | -| `login` + `zsh` session pairs | 506 | -| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | -| Sessions from dev-build Orca instances | ~25 | -| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | -| `claude` agent processes | ~38 | -| Processes using >1% CPU | 7 of 763 — everything else fully idle | - -Key observations: - -- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. -- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). -- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. -- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). -- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. - ---- - -## 3. Background: pty ownership architecture - -- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). -- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). -- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. -- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. - -Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. - ---- - -## 4. Bug A: closing a tab never kills the pty - -### The close path contains no kill - -`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. - -### The only kill is a React-unmount side effect - -The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. - -This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** - -### Nothing else ever reaps it - -Every existing reaper is scoped to an event _other than_ tab close: - -| Mechanism | Why it doesn't help | -| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | -| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | -| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | -| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | -| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | - -### The codebase already knows close ≠ kill - -`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. - ---- - -## 5. Bug B: closed sessions are resurrected in bulk forever - -### The resume list never learns about tab close - -Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: - -- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). -- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). - -`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). - -### The staleness gate is a no-op - -On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: - -- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. -- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." -- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. - -Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). - -### The feedback loop - -1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. -2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. -3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. -4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. -5. Next open/restart → goto 1, with a strictly larger backlog. - -Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. - ---- - -## 6. Proposed fixes - -### Fix 1 (primary, Bug A): make tab close actually kill the pty - -In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. - -Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. - -### Fix 2 (primary, Bug B): remove resume records on tab close - -Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. - -### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry - -Do not globally expire resume records by wall time: intentional `worktree-sleep` -checkpoints may remain valid for days. A defensive expiry can apply only to -unowned `live` or `quit` records after their origin and ownership are validated -at every wake entry point. Explicit tab close must delete its records -immediately and remains the primary fix. - -### Fix 4 (secondary, Bug A): daemon-side reaper - -Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. - -### Defense in depth - -- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. -- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. - ---- - -## 7. Verification plan - -1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. -2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). -3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. -4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. - -## 8. Immediate mitigation (ops, no code) - -- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination - to release the leaked ptys. A normal Orca.app restart is insufficient because - the persistent terminal daemon deliberately survives for warm reattach. -- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. -- Restart the terminal daemon only when exact-session cleanup is unavailable; - this terminates all daemon-backed sessions, including legitimate in-flight work. - ---- - -## Appendix: diagnostic commands used - -```sh -ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap -ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys -lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts -lsof -p | grep -c ptmx # master fds per Orca Helper -ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes -``` diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 00000000000..fca1162fa5e --- /dev/null +++ b/resources/skills/current-manifest.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "appVersion": "1.4.144-rc.1", + "skills": [ + { + "name": "computer-use", + "sourcePath": "skills/computer-use", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + }, + { + "name": "linear-tickets", + "sourcePath": "skills/linear-tickets", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + }, + { + "name": "orca-cli", + "sourcePath": "skills/orca-cli", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + }, + { + "name": "orca-emulator", + "sourcePath": "skills/orca-emulator", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + }, + { + "name": "orca-emulator-android", + "sourcePath": "skills/orca-emulator-android", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + }, + { + "name": "orca-linear", + "sourcePath": "skills/orca-linear", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + }, + { + "name": "orca-per-workspace-env", + "sourcePath": "skills/orca-per-workspace-env", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + }, + { + "name": "orchestration", + "sourcePath": "skills/orchestration", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 00000000000..12baa4b6e7f --- /dev/null +++ b/resources/skills/release-mapping.json @@ -0,0 +1,501 @@ +{ + "schemaVersion": 1, + "releases": [ + { + "appVersion": "1.0.81", + "skills": { + "orca-cli": 1 + } + }, + { + "appVersion": "1.0.86", + "skills": { + "orca-cli": 2 + } + }, + { + "appVersion": "1.0.94-rc.0", + "skills": { + "orca-cli": 3 + } + }, + { + "appVersion": "1.3.8-rc.1", + "skills": { + "orca-cli": 4 + } + }, + { + "appVersion": "1.3.12-rc.4", + "skills": { + "orca-cli": 5 + } + }, + { + "appVersion": "1.3.24-rc.2", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.33-rc.3", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "0.0.1", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.35-rc.0", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38-rc.3", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.39", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.46-rc.3", + "skills": { + "orca-cli": 7, + "orchestration": 2 + } + }, + { + "appVersion": "1.3.47-rc.0", + "skills": { + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.3.49-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.4.2-rc.10", + "skills": { + "computer-use": 1, + "orca-cli": 9, + "orchestration": 4 + } + }, + { + "appVersion": "1.4.2", + "skills": { + "computer-use": 1, + "orca-cli": 10, + "orchestration": 5 + } + }, + { + "appVersion": "1.4.3-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 11, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.9-rc.1", + "skills": { + "computer-use": 1, + "orca-cli": 12, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.10-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 13, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.11-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 14, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 15, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.22", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 7 + } + }, + { + "appVersion": "1.4.36-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 17, + "orchestration": 8 + } + }, + { + "appVersion": "1.4.36-rc.6", + "skills": { + "computer-use": 1, + "orca-cli": 18, + "orchestration": 9 + } + }, + { + "appVersion": "1.4.42-rc.8", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 10 + } + }, + { + "appVersion": "1.4.46-rc.0", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.8", + "skills": { + "computer-use": 3, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.9", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.66-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.67-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.68", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 13 + } + }, + { + "appVersion": "1.4.74-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 21, + "orca-emulator": 1, + "orchestration": 14 + } + }, + { + "appVersion": "1.4.78-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 22, + "orca-emulator": 1, + "orchestration": 15 + } + }, + { + "appVersion": "1.4.78-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 23, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 24, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 25, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.88", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 26, + "orca-emulator": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.90", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 26, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.92-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 27, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.96-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 28, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.105-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.111-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.6", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.9", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 19 + } + }, + { + "appVersion": "1.4.124", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 20 + } + }, + { + "appVersion": "1.4.137-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 30, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 21 + } + }, + { + "appVersion": "1.4.138-rc.0", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 22 + } + }, + { + "appVersion": "1.4.138-rc.3", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 23 + } + }, + { + "appVersion": "1.4.138-rc.4", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + }, + { + "appVersion": "1.4.141", + "skills": { + "computer-use": 5, + "linear-tickets": 4, + "orca-cli": 32, + "orca-emulator": 4, + "orca-emulator-android": 2, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 00000000000..58d6c7f6c0e --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1239 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + }, + { + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/resources/tray/orca-menu-barTemplate.png b/resources/tray/orca-menu-barTemplate.png new file mode 100644 index 00000000000..b98b90f2fda Binary files /dev/null and b/resources/tray/orca-menu-barTemplate.png differ diff --git a/resources/tray/orca-menu-barTemplate@2x.png b/resources/tray/orca-menu-barTemplate@2x.png new file mode 100644 index 00000000000..fffb571fe76 Binary files /dev/null and b/resources/tray/orca-menu-barTemplate@2x.png differ diff --git a/src/cli/args.ts b/src/cli/args.ts index a3dc0df5349..4392d64314c 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -58,6 +58,7 @@ export const BOOLEAN_FLAGS = new Set([ 'run-hooks', 'show-profile', 'staged', + 'tab', 'tasks', 'text-stdin', 'unread', diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts new file mode 100644 index 00000000000..7dadf41c2d3 --- /dev/null +++ b/src/cli/handlers/terminal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClient } from '../runtime-client' +import { parseArgs } from '../args' +import { printHelp } from '../help' +import { COMMAND_SPECS } from '../specs' +import { TERMINAL_HANDLERS } from './terminal' + +describe('terminal close CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps the default close RPC unchanged', async () => { + const call = vi.fn().mockResolvedValue({ + result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenCalledWith('terminal.close', { terminal: 'term-1' }) + }) + + it('routes --tab to the durable whole-tab RPC', async () => { + const parsed = parseArgs(['terminal', 'close', '--terminal', 'term-1', '--tab']) + const call = vi.fn().mockResolvedValue({ + result: { + close: { + handle: 'term-1', + tabId: 'tab-1', + closeMode: 'tab', + ptyKilled: false + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: parsed.flags, + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(parsed.flags.get('tab')).toBe(true) + expect(call).toHaveBeenCalledWith('terminal.closeTab', { terminal: 'term-1' }) + }) + + it('documents that --tab waits for durable persistence', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + printHelp(COMMAND_SPECS, ['terminal', 'close']) + + const help = String(log.mock.calls[0]?.[0]) + expect(help).toContain('orca terminal close [--terminal ] [--tab] [--json]') + expect(help).toContain('durable persistence') + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index fb2eefc3748..1359f5cce13 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -150,7 +150,8 @@ export const TERMINAL_HANDLERS: Record = { // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. 'terminal switch': terminalFocusHandler, 'terminal close': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close' + const result = await client.call<{ close: RuntimeTerminalClose }>(method, { terminal: await getTerminalHandle(flags, cwd, client) }) printResult(result, json, formatTerminalClose) diff --git a/src/cli/help.ts b/src/cli/help.ts index 379bfbce1e0..11d9fd2dc20 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,7 +82,7 @@ Terminals: terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground terminal focus Alias for terminal switch - terminal close Close a terminal pane (or tab if last pane) + terminal close Close a terminal pane/session, or its whole tab with --tab Orchestration: orchestration send Send an inter-agent message @@ -224,7 +224,7 @@ Common Commands: orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--json] + orca terminal close [--terminal ] [--tab] [--json] orca project list [--json] orca project setups [--project ] [--host ] [--json] orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] @@ -401,6 +401,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'terminal close' && flag === 'tab') { + return '--tab Close the whole tab and wait for durable persistence' + } if (command === 'linear issue' && flag === 'id') { return '--id Linear issue key, id, or URL' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 3c89c5ff3a5..41d001daf58 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -254,10 +254,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'close'], - summary: 'Close a terminal tab (kills PTY if running)', - usage: 'orca terminal close [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal close --terminal term_abc123'] + summary: 'Close a terminal pane/session, or its whole tab with --tab', + usage: 'orca terminal close [--terminal ] [--tab] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab'], + notes: [ + 'Without --tab, preserves the existing pane/session close behavior. With --tab, waits until the whole tab is durably removed.' + ], + examples: [ + 'orca terminal close --terminal term_abc123', + 'orca terminal close --terminal term_abc123 --tab --json' + ] }, { path: ['terminal', 'rename'], diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index a0791d6e038..a6fbd6a9514 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -168,6 +168,9 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st } export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + if (result.close.closeMode === 'tab') { + return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).` + } const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' return `Closed terminal ${result.close.handle}.${ptyNote}` } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 7423be836dd..1239e6dd9eb 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -6046,6 +6046,62 @@ describe('Last-status persistence', () => { } }) + it('drops persisted idle Claude child rows from hydration replay', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: recentTs(-1000), + payload: { + state: 'done', + prompt: 'finished orchestration', + agentType: 'claude', + subagents: [ + { id: 'aweb-research-8a76b7d7', state: 'idle', startedAt: receivedAt - 5000 }, + { id: 'apr-history-9b87c6e6', state: 'idle', startedAt: receivedAt - 4000 } + ] + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ subagents: undefined }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'finished orchestration', + subagents: undefined + }) + ]) + // Why: make the migration one-time; otherwise every launch reparses and + // re-prunes the same persisted idle rows. + const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(persisted.entries[PANE].payload.subagents).toBeUndefined() + } finally { + server.stop() + } + }) + it('treats a corrupt file as empty hydration without throwing', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 513735bd679..4011fdabda6 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -174,6 +174,24 @@ export function isValidPaneKey(value: unknown): value is string { ) } +function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working') + // Why: older builds persisted finished Claude children as idle rows. Prune + // them from the replay payload itself so restart cannot resurrect the pile. + return { + ...payload, + subagents: workingSubagents.length > 0 ? workingSubagents : undefined + } +} + function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -1758,6 +1776,7 @@ export class AgentHookServer { } let hydrated = 0 let dropped = 0 + let prunedLegacyClaudeSubagents = 0 // Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so // entries from worktrees archived weeks ago do not pile up forever. Use // Date.now() once to keep the cutoff consistent across all entries this @@ -1771,11 +1790,15 @@ export class AgentHookServer { : { ...(rawEntry as Record), paneKey: resolvedPaneKey } const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - // Why: the in-memory subagent roster died with the previous process. - // Reseed it from the persisted snapshot so the next teammate-bearing - // Stop (whose task ids never match lifecycle ids) doesn't silently - // drop the replayed child rows. + // Why: preserve only working children across restart. Live activity + // confirms them; a later complete inventory may reap stale seeds. if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, @@ -1793,18 +1816,16 @@ export class AgentHookServer { `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` ) } - if (hydrated > 0 && dropped === 0) { + if (dropped > 0 || prunedLegacyClaudeSubagents > 0) { + // Why: persist load-time pruning once so legacy idle rows do not consume + // parse/filter work again on every launch. + this.runStatusPersist() + } else if (hydrated > 0) { // Why: prime from the raw on-disk bytes (not a re-serialization) so the // dedup is robust against future shape drift in serializeStatusFile. // Only prime when hydration was lossless — if entries were dropped // during sanitize, the in-memory map diverges from the on-disk bytes. this.lastWrittenJson = raw - } else if (dropped > 0) { - // Why: clean the stale on-disk file now so a user who has not run an - // agent in 8+ days does not re-drop the same entries on every cold - // boot. Synchronous variant is safe at start time and avoids - // unref'd-timer-during-quit edge cases. - this.runStatusPersist() } } diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts new file mode 100644 index 00000000000..1791baa82cd --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -0,0 +1,168 @@ +import { extname } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const REMOTE_DISCOVERY_CONCURRENCY = 8 + +export async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const walked = args.source.fixedChildFileSegments + ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) + : await walkRemoteSessionFiles(args.source, args.context, args.issues) + const partition = args.source.collectSubagentSiblingCounts + ? partitionSubagentTranscriptPaths(walked) + : null + const paths = partition ? partition.sessionFilePaths : walked + const files = await mapDiscoveryConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + Boolean(args.source.fixedChildFileSegments) + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) +} + +async function listRemoteFixedChildFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + let entries + try { + entries = await context.provider.readDir(source.rootDir) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) + return [] + } + const segments = source.fixedChildFileSegments ?? [] + // Why: Antigravity's transcript path is fixed. Constructing it avoids three + // serialized SSH readDir round trips for every conversation directory. + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[], + dirPath = source.rootDir, + depth = 0 +): Promise { + let entries + try { + entries = await context.provider.readDir(dirPath) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + if ( + entry.isDirectory && + !entry.isSymlink && + (source.directoryPredicate?.(entry.name, depth) ?? true) + ) { + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + missingIsExpected: boolean +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + if (!missingIsExpected || !isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + } + return null + } +} + +function recordRemoteDirectoryIssue( + source: RemoteSessionSource, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + path: string, + err: unknown +): void { + if (!isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + } +} + +function isMissingRemotePathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +async function mapDiscoveryConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..c33a0ae7701 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' @@ -49,6 +51,7 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. collectSubagentSiblingCounts: true }, + remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', remoteHome, @@ -108,6 +111,30 @@ export function remoteSessionSources( ] } +function remoteAntigravitySource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') + const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + return { + agent: 'antigravity', + rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], + parse: async (file, content, context) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context) + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } + } +} + function source( agent: AiVaultAgent, remoteHome: string, @@ -115,13 +142,15 @@ function source( segments: readonly string[], extensions: readonly string[], parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean + filePredicate?: (path: string) => boolean, + directoryPredicate?: (name: string, depth: number) => boolean ): RemoteSessionSource { return { agent, rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), extensions, filePredicate, + directoryPredicate, parse: (file, content, context) => Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..4da4bd3f4a6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,12 +3,14 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform titleCaches: Map>> + antigravityWorkspaceResolver: AntigravityWorkspaceResolver } export type RemoteParserOptions = { @@ -21,6 +23,10 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Depth 0 denotes a direct child of rootDir. + directoryPredicate?: (name: string, depth: number) => boolean + // A canonical file directly beneath every top-level session directory. + fixedChildFileSegments?: readonly string[] // Claude layout: count `/subagents/*.jsonl` siblings from the walked // listing and drop them from candidates instead of indexing them as sessions. collectSubagentSiblingCounts?: boolean diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..4f9cbd73bb0 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -6,13 +6,29 @@ import { scanRemoteAiVaultSessions } from './remote-session-scanner' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] addFile(path: string, content: string, mtimeMs: number): void { this.files.set(normalize(path), { content, mtimeMs }) } + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + async readDir(dirPath: string): Promise { const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } const prefix = dir.endsWith('/') ? dir : `${dir}/` const entries = new Map() for (const path of this.files.keys()) { @@ -45,6 +61,10 @@ class MemoryRemoteProvider implements IFilesystemProvider { } async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } const file = this.files.get(normalize(filePath)) if (!file) { throw new Error(`ENOENT: ${filePath}`) @@ -201,6 +221,184 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` + provider.addFile( + `${logsDir}/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Fix remote Antigravity history' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-07-15T11:39:12Z', + content: 'Done' + } + ]), + 50 + ) + provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) + provider.addFile( + `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, + 'not a transcript', + 52 + ) + provider.addFile( + '/home/ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Fix remote Antigravity history', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/home/ada/project' + } + ]), + 53 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/project'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'antigravity', + sessionId, + title: 'Fix remote Antigravity history', + cwd: '/home/ada/project', + messageCount: 2, + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: `${logsDir}/transcript.jsonl` + }) + expect( + provider.readDirPaths.filter((path) => + path.startsWith('/home/ada/.gemini/antigravity-cli/brain') + ) + ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) + }) + + it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + for (let index = 0; index < 40; index++) { + provider.addFile( + `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, + content: `Remote session ${index}` + } + ]), + 100 + index + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(40) + expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) + }) + + it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) + const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` + provider.addFile(deniedTranscript, 'unreadable', 2) + provider.failStat( + deniedTranscript, + new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'antigravity', + path: deniedTranscript, + message: expect.stringContaining('EACCES') + }) + ]) + }) + + it('reports non-missing fixed and recursive remote directory failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + const claudeProjectDir = '/home/ada/.claude/projects/repo' + provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) + provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) + provider.failReadDir( + claudeProjectDir, + new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: 'antigravity', + path: brainDir, + message: expect.stringContaining('EACCES') + }), + expect.objectContaining({ + agent: 'claude', + path: claudeProjectDir, + message: expect.stringContaining('ECONNRESET') + }) + ]) + ) + expect(result.issues).toHaveLength(2) + }) + + it('keeps missing optional remote directories silent', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([]) + }) + it('excludes Claude subagent transcripts from remote scans', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -357,6 +555,49 @@ describe('scanRemoteAiVaultSessions', () => { ) }) + it('loads Antigravity workspace history with Windows remote paths', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' + provider.addFile( + `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Windows Antigravity title' + } + ]), + 30 + ) + provider.addFile( + 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Windows Antigravity title', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: 'C:/repo/app' + } + ]), + 31 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + cwd: 'C:/repo/app', + executionHostPlatform: 'win32' + }) + }) + it('continues past skipped candidates to fill the remote scan limit', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..c8e07929b61 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,25 +1,18 @@ -import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import type { FileWithMtime } from './session-scanner-types' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +32,15 @@ export async function scanRemoteAiVaultSessions(args: { provider: args.provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, - titleCaches: new Map() + titleCaches: new Map(), + antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { + try { + const read = await args.provider.readFile(historyPath) + return read.isBinary ? null : read.content + } catch { + return null + } + }) } const candidates = ( await mapRemoteScanConcurrently( @@ -73,70 +74,6 @@ export async function scanRemoteAiVaultSessions(args: { } } -async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - const walked = await walkRemoteSessionFiles( - args.source, - args.context.provider, - args.context.hostPlatform - ) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues - ) - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - provider: IFilesystemProvider, - hostPlatform: RemoteHostPlatform, - dirPath = source.rootDir -): Promise { - let entries - try { - entries = await provider.readDir(dirPath) - } catch { - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) - if (entry.isDirectory && !entry.isSymlink) { - files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - async function parseRemoteSessionCandidates(args: { candidates: readonly RemoteSessionCandidate[] context: RemoteScannerContext @@ -254,30 +191,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 22c6cd46abc..029227d8745 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' @@ -37,6 +38,8 @@ export async function parseAgentSessionFile( return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) case 'gemini': return parseGeminiSessionFile(candidate.file, platform) + case 'antigravity': + return parseAntigravitySessionFile(candidate.file, platform) case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': diff --git a/src/main/ai-vault/session-scanner-antigravity-fixtures.ts b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts new file mode 100644 index 00000000000..cbcab494933 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts @@ -0,0 +1,35 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +export function antigravityFixture(): IncrementalAgentFixture { + const record = (source: string, type: string, content: string, createdAt: string) => + JSON.stringify({ source, type, content, created_at: createdAt }) + return { + agent: 'antigravity', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/.system_generated/logs/transcript.jsonl', + seedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity seed question', + '2026-05-01T10:00:00.000Z' + ), + record('MODEL', 'PLANNER_RESPONSE', 'antigravity seed answer', '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + record( + 'USER', + 'REQUEST', + 'antigravity follow-up', + '2026-05-01T10:01:00.000Z' + ) + ], + truncatedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity rewritten', + '2026-05-01T10:00:00.000Z' + ) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts new file mode 100644 index 00000000000..989ac7b4179 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -0,0 +1,77 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' + +const HISTORY_MATCH_WINDOW_MS = 2_000 + +type AntigravityHistoryEntry = { + timestampMs: number + workspace: string +} + +type AntigravityHistoryIndex = Map + +export type AntigravityWorkspaceResolver = { + enrich(session: AiVaultSession, historyPath: string): Promise +} + +export function createAntigravityWorkspaceResolver( + readHistory: (historyPath: string) => Promise +): AntigravityWorkspaceResolver { + const indexes = new Map>() + + return { + async enrich(session, historyPath) { + if (session.agent !== 'antigravity' || session.cwd) { + return session + } + let index = indexes.get(historyPath) + if (!index) { + index = readHistory(historyPath).then(indexAntigravityHistory) + indexes.set(historyPath, index) + } + const workspace = findAntigravityWorkspace(session, await index) + return workspace ? { ...session, cwd: workspace } : session + } + } +} + +function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex { + const index: AntigravityHistoryIndex = new Map() + for (const line of content?.split(/\r?\n/) ?? []) { + const record = parseJsonObject(line) + const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null + const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : '' + const entryTimestampMs = timestampMs(record?.timestamp) + if (!display || !workspace || !Number.isFinite(entryTimestampMs)) { + continue + } + const entries = index.get(display) ?? [] + entries.push({ timestampMs: entryTimestampMs, workspace }) + index.set(display, entries) + } + return index +} + +function findAntigravityWorkspace( + session: AiVaultSession, + index: AntigravityHistoryIndex +): string | null { + // Why: truncated titles are not prompt identities; long worker prompts often + // share the same 96-character prefix across unrelated workspaces. + if (session.title.endsWith('...')) { + return null + } + const firstTitledUserTimestamp = session.previewMessages.find( + (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title + )?.timestamp + const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt) + if (!Number.isFinite(promptTimestampMs)) { + return null + } + const matches = (index.get(session.title) ?? []).filter( + (entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS + ) + // Why: history rows have no conversation id. A unique prompt/time match is + // evidence for cwd; ambiguity must stay unknown instead of crossing projects. + return matches.length === 1 ? (matches[0]?.workspace ?? null) : null +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.test.ts b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts new file mode 100644 index 00000000000..c8d037098ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import type { FileWithMtime } from './session-scanner-types' +import { jsonLines } from './session-scanner-test-fixtures' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('Antigravity AI Vault parser', () => { + it('parses the established transcript contract without leaking metadata wrappers', async () => { + const session = await parseAntigravitySessionContent( + file( + `/home/ada/.gemini/antigravity-cli/brain/${SESSION_ID}/.system_generated/logs/transcript.jsonl` + ), + jsonLines([ + { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + status: 'DONE', + created_at: '2026-07-15T11:39:10Z', + content: + '\nFix the vault adapter\n\nignored' + }, + { + step_index: 1, + source: 'SYSTEM', + type: 'CONVERSATION_HISTORY', + created_at: '2026-07-15T11:39:11Z' + }, + { + step_index: 2, + source: 'MODEL', + type: 'PLANNER_RESPONSE', + status: 'DONE', + created_at: '2026-07-15T11:39:12Z', + content: 'The adapter is ready.' + }, + { + step_index: 3, + source: 'SYSTEM', + type: 'CHECKPOINT', + created_at: '2026-07-15T11:39:14Z', + content: 'internal checkpoint' + } + ]), + 'linux' + ) + + expect(session).toMatchObject({ + agent: 'antigravity', + sessionId: SESSION_ID, + title: 'Fix the vault adapter', + cwd: null, + model: null, + createdAt: '2026-07-15T11:39:10.000Z', + updatedAt: '2026-07-15T11:39:14.000Z', + messageCount: 2, + resumeCommand: `agy --conversation '${SESSION_ID}'` + }) + expect(session?.previewMessages).toEqual([ + { + role: 'user', + text: 'Fix the vault adapter', + timestamp: '2026-07-15T11:39:10.000Z' + }, + { + role: 'assistant', + text: 'The adapter is ready.', + timestamp: '2026-07-15T11:39:12.000Z' + } + ]) + }) + + it('derives the conversation id from Windows paths and quotes the resume command', async () => { + const session = await parseAntigravitySessionContent( + file( + `C:\\Users\\Ada\\.gemini\\antigravity-cli\\brain\\${SESSION_ID}\\.system_generated\\logs\\transcript.jsonl` + ), + jsonLines([ + { + source: 'USER', + type: 'REQUEST', + created_at: '2026-07-15T11:39:10Z', + content: 'Windows prompt' + } + ]), + 'win32' + ) + + expect(session).toMatchObject({ + sessionId: SESSION_ID, + title: 'Windows prompt', + resumeCommand: `agy --conversation "${SESSION_ID}"` + }) + }) + + it('rejects files outside the targeted Antigravity transcript layout', async () => { + await expect( + parseAntigravitySessionContent( + file('/home/ada/.gemini/antigravity-cli/brain/transcript.jsonl'), + jsonLines([{ source: 'USER', type: 'REQUEST', content: 'Not a conversation transcript' }]) + ) + ).resolves.toBeNull() + }) +}) + +function file(path: string): FileWithMtime { + return { + path, + mtimeMs: Date.parse('2026-07-15T11:39:14Z'), + modifiedAt: '2026-07-15T11:39:14.000Z', + sizeBytes: 1 + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts new file mode 100644 index 00000000000..2e3a3a08b4a --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + updateTimeline +} from './session-scanner-accumulator' +import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseAntigravitySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseAntigravitySessionLines({ file, lines, platform }) +} + +export async function parseAntigravitySessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseAntigravitySessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +export function createAntigravitySessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' + // Why: the transcript has no cwd/model fields. Workspace enrichment is a + // separate, conservative history join; protobuf/SQLite blobs are unstable. + return accumulatorFoldResumeState( + createAccumulator({ agent: 'antigravity', file, sessionId }), + consumeAntigravityRecordLine + ) +} + +async function parseAntigravitySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createAntigravitySessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + + updateTimeline(accumulator, record.created_at) + const source = extractString(record.source) + const type = extractString(record.type) + const content = extractString(record.content) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const request = extractAntigravityUserRequest(content ?? '') + if (!request) { + return + } + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText(request) + addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at }) + return + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE' && content) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: content, + timestamp: record.created_at + }) + } +} + +function extractAntigravityUserRequest(content: string): string | null { + const opener = '' + const startIndex = content.indexOf(opener) + if (startIndex === -1) { + return extractString(content) + } + const bodyStart = startIndex + opener.length + const endIndex = content.indexOf('', bodyStart) + return extractString( + endIndex === -1 ? content.slice(bodyStart) : content.slice(bodyStart, endIndex) + ) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts new file mode 100644 index 00000000000..c505cb7e5ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts @@ -0,0 +1,42 @@ +import { dirname, join } from 'node:path' + +const ANTIGRAVITY_TRANSCRIPT_FILE = 'transcript.jsonl' +const ANTIGRAVITY_SYSTEM_DIR = '.system_generated' +const ANTIGRAVITY_LOGS_DIR = 'logs' + +export function antigravityConversationIdFromTranscriptPath(filePath: string): string | null { + const segments = pathSegments(filePath) + const transcriptIndex = segments.length - 1 + if ( + segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE || + segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR || + segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR + ) { + return null + } + return segments[transcriptIndex - 3] ?? null +} + +export function isAntigravityTranscriptPath(filePath: string): boolean { + return antigravityConversationIdFromTranscriptPath(filePath) !== null +} + +export function shouldDescendAntigravityBrainDirectory(name: string, depth: number): boolean { + // Why: brain entries contain large artifact trees; only the fixed transcript + // chain is part of the resumable conversation contract. + if (depth === 0) { + return true + } + if (depth === 1) { + return name === ANTIGRAVITY_SYSTEM_DIR + } + return depth === 2 && name === ANTIGRAVITY_LOGS_DIR +} + +export function antigravityHistoryPathForBrainDir(brainDir: string): string { + return join(dirname(brainDir), 'history.jsonl') +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]+/).filter(Boolean) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-source.test.ts b/src/main/ai-vault/session-scanner-antigravity-source.test.ts new file mode 100644 index 00000000000..454b3cf92f6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-source.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { + isolatedScanRoots, + writeAntigravityHistory, + writeAntigravityTranscript +} from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Antigravity AI Vault discovery', () => { + it('discovers canonical transcripts from WSL homes without indexing sibling artifacts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-wsl-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const wslHome = join(root, 'wsl-home') + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const transcriptPath = await writeAntigravityTranscript( + join(wslHome, '.gemini', 'antigravity-cli', 'brain'), + sessionId, + [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Index the WSL conversation' + } + ] + ) + await writeFile(join(dirname(transcriptPath), 'transcript_full.jsonl'), 'duplicate') + await writeAntigravityHistory(join(wslHome, '.gemini', 'antigravity-cli', 'brain'), [ + { + display: 'Index the WSL conversation', + timestamp: Date.parse('2026-07-15T11:39:10.100Z') / 1000, + workspace: '/home/ada/project' + } + ]) + + const result = await scanAiVaultSessions({ + ...roots, + wslHomeDirs: [wslHome], + platform: 'linux' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + title: 'Index the WSL conversation', + cwd: '/home/ada/project', + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: transcriptPath + }) + }) + + it('uses a unique history match in the default workspace view', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-workspace-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + const workspace = join(root, 'active-workspace') + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'ORCA_8742_REPRO' + } + ]) + + const beforeHistory = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(beforeHistory.sessions[0]?.cwd).toBeNull() + + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'ORCA_8742_REPRO', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace + } + ]) + // The transcript is unchanged, so this also covers history refresh after a + // cached incremental parse has already been stored. + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions[0]?.cwd).toBe(workspace) + expect( + filterAiVaultSessions(result.sessions, { + query: '', + agents: AI_VAULT_AGENTS, + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: true + }).map((session) => session.sessionId) + ).toEqual([sessionId]) + }) + + it('keeps workspace unknown when matching history rows are ambiguous', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-ambiguous-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa' + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Repeated prompt' + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:09.900Z'), + workspace: '/repo/one' + }, + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/two' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.cwd).toBeNull() + }) + + it('does not join workspace from a colliding truncated prompt title', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-long-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'eeeeeeee-ffff-4aaa-8bbb-cccccccccccc' + const commonPrefix = 'shared worker preamble '.repeat(7) + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: `${commonPrefix}workspace A` + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: `${commonPrefix}workspace B`, + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/wrong' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.title).toHaveLength(96) + expect(result.sessions[0]?.title).toMatch(/\.\.\.$/) + expect(result.sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts new file mode 100644 index 00000000000..a79edc380e6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + isAntigravityTranscriptPath, + shouldDescendAntigravityBrainDirectory +} from './session-scanner-antigravity-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain') + +export function antigravityDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const rootDirs = [ + options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain')) + ] + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'antigravity', + issues, + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + directoryPredicate: shouldDescendAntigravityBrainDirectory + }) + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 8a6fb183f62..d8a8e2850ac 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -106,6 +106,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir, geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..576438a8c49 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,7 +11,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), @@ -70,10 +70,11 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory (matched by its name), - // so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string) => boolean - } + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, + depth = 0 ): Promise { let entries try { @@ -88,8 +89,8 @@ export async function walkSessionFiles( if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } continue } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 1945193e6c7..54f0171e6c6 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -1,5 +1,6 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' // Line builders for the incremental-parse differential tests: each agent gets @@ -286,6 +287,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), - geminiJsonlFixture() + geminiJsonlFixture(), + antigravityFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 2fb3df21613..1819afe81f2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -23,6 +23,7 @@ function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index 61348ad7cec..cd4977679d2 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { @@ -55,6 +55,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${fixture.seedLines.join('\n')}\n`) const stats = createSessionParseStats() @@ -87,6 +88,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('includes a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) const lastSeedLine = fixture.seedLines.at(-1) const headLines = fixture.seedLines.slice(0, -1) await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) @@ -106,6 +108,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) // A writer caught mid-record: the trailing line is invalid JSON. await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..d42da123437 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' @@ -67,6 +68,8 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null + case 'antigravity': + return () => createAntigravitySessionResumeState(candidate.file) case 'devin': case 'grok': case 'hermes': diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts index 77931d68715..487748de97a 100644 --- a/src/main/ai-vault/session-scanner-scope.test.ts +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -19,6 +19,7 @@ function scopedScanOptions(claudeProjectsDir: string, extra: Partial[] { return [ + ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ '.gemini', 'tmp' diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0a3b6c7c1a8..f69bf2939c7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -6,6 +6,7 @@ export function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), @@ -34,3 +35,37 @@ export async function writeJsonlFile(filePath: string, records: unknown[]): Prom await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, jsonLines(records)) } + +export async function writeAntigravityTranscript( + brainDir: string, + sessionId: string, + records: unknown[] +): Promise { + const transcriptPath = join(brainDir, sessionId, '.system_generated', 'logs', 'transcript.jsonl') + await writeJsonlFile(transcriptPath, records) + return transcriptPath +} + +export function writeAntigravityHistory(brainDir: string, records: unknown[]): Promise { + return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) +} + +export function writeAntigravityScannerFixture( + brainDir: string, + sessionId: string +): Promise { + return writeAntigravityTranscript(brainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-05-01T10:02:30.000Z', + content: 'Antigravity title' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-05-01T10:02:31.000Z', + content: 'Done' + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..efc6660ab1a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -12,6 +12,7 @@ export type AiVaultScanOptions = { additionalCodexSessionsDirs?: readonly string[] wslHomeDirs?: readonly string[] geminiSessionsDir?: string + antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string @@ -52,6 +53,7 @@ export type SessionFileCandidate = { agent: AiVaultAgent file: FileWithMtime codexHome: string | null + antigravityHistoryPath?: string } export type SessionFileDiscovery = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d4404341fc1..7ba80556051 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeAntigravityScannerFixture +} from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -423,6 +427,9 @@ describe('scanAiVaultSessions', () => { }) ) + const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId) + await mkdir(roots.copilotSessionsDir, { recursive: true }) await writeFile( join(roots.copilotSessionsDir, 'copilot-session.jsonl'), @@ -718,11 +725,7 @@ describe('scanAiVaultSessions', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 20 - }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -739,6 +742,7 @@ describe('scanAiVaultSessions', () => { `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` ) expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('antigravity')).toBe(`agy --conversation '${antigravitySessionId}'`) expect(commandByAgent.get('copilot')).toBe( "cd '/tmp/copilot' && copilot --resume='copilot-session'" ) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..ddb9ba7a861 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,6 +7,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + createAntigravityWorkspaceResolver, + type AntigravityWorkspaceResolver +} from './session-scanner-antigravity-history' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -54,6 +60,7 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -65,7 +72,11 @@ export async function scanAiVaultSessions( codexHome: discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined }) ) ) @@ -77,7 +88,8 @@ export async function scanAiVaultSessions( platform, executionHostId, issues, - parseStats + parseStats, + antigravityWorkspaceResolver }) const cappedSessions = parsedSessions @@ -175,6 +187,7 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -190,7 +203,13 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) + parseSessionCandidate( + candidate, + args.platform, + args.executionHostId, + args.parseStats, + args.antigravityWorkspaceResolver + ) ) ) @@ -213,10 +232,14 @@ async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, executionHostId: ExecutionHostId, - parseStats: SessionParseStats + parseStats: SessionParseStats, + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver ): Promise { try { - const session = await parseAgentSessionFileCached(candidate, platform, parseStats) + let session = await parseAgentSessionFileCached(candidate, platform, parseStats) + if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { + session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) + } return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null @@ -234,6 +257,14 @@ async function parseSessionCandidate( } } +async function readOptionalTextFile(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts new file mode 100644 index 00000000000..eee5eea35f1 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript, + installBrowserIframeClickedLinkRouting, + installBrowserClickedLinkRouting +} from './browser-clicked-link-routing' + +const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test' + +type RoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: { + listener: (event: MouseEvent) => void + } +} + +let cleanupIframeRouting: (() => void) | null = null + +function resetRouting(): void { + const routingGlobal = globalThis as RoutingGlobal + const state = routingGlobal.__orcaBrowserClickedLinkRouting + if (state) { + window.removeEventListener('click', state.listener) + window.removeEventListener('auxclick', state.listener) + } + delete routingGlobal.__orcaBrowserClickedLinkRouting + cleanupIframeRouting?.() + cleanupIframeRouting = null +} + +function installRouting(isMac = true): void { + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, isMac, true) +} + +function clickLink( + link: Element, + init: MouseEventInit & { type?: 'click' | 'auxclick' } = {} +): { event: MouseEvent; open: ReturnType } { + const open = vi.fn() + vi.spyOn(window, 'open').mockImplementation(open) + const { type = 'click', ...eventInit } = init + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: type === 'auxclick' ? 1 : 0, + ...eventInit + }) + link.dispatchEvent(event) + return { event, open } +} + +describe('browser clicked-link routing', () => { + beforeEach(() => { + resetRouting() + document.head.innerHTML = '' + document.body.innerHTML = '' + }) + + afterEach(() => { + resetRouting() + vi.restoreAllMocks() + }) + + it('routes plain target=_blank links back into the current Orca tab', () => { + const link = document.createElement('a') + link.href = 'https://docs.example.com/guide' + link.target = '_blank' + document.body.append(link) + installRouting() + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_self') + }) + + it('routes the host-platform modifier without trusting an emulated guest user agent', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' + }) + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting(false) + + expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('routes middle clicks and primary-modifier plus Shift clicks as new-tab intents', () => { + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting() + + expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('honors page cancellation and observes link rewrites before routing', () => { + const cancelled = document.createElement('a') + cancelled.href = 'https://example.com/original' + cancelled.target = '_blank' + cancelled.addEventListener('click', (event) => event.preventDefault()) + const rewritten = document.createElement('a') + rewritten.href = 'https://example.com/original' + rewritten.target = '_blank' + rewritten.addEventListener('click', () => { + rewritten.href = 'https://example.com/rewritten' + }) + document.body.append(cancelled, rewritten) + installRouting() + + expect(clickLink(cancelled).open).not.toHaveBeenCalled() + expect(cancelled.getAttribute('target')).toBe('_blank') + expect(clickLink(rewritten).open).not.toHaveBeenCalled() + expect(rewritten.href).toBe('https://example.com/rewritten') + expect(rewritten.getAttribute('target')).toBe('_self') + }) + + it('routes SVG links but leaves download links and links without href alone', () => { + const svgLink = document.createElementNS('http://www.w3.org/2000/svg', 'a') + svgLink.setAttribute('href', 'https://example.com/svg') + svgLink.setAttribute('target', '_blank') + const areaDownload = document.createElement('area') + areaDownload.href = 'https://example.com/archive.zip' + areaDownload.target = '_blank' + areaDownload.download = 'archive.zip' + const noHref = document.createElement('a') + noHref.target = '_blank' + document.body.append(svgLink, areaDownload, noHref) + installRouting() + + expect(clickLink(svgLink).open).not.toHaveBeenCalled() + expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(areaDownload).open).not.toHaveBeenCalled() + expect(clickLink(noHref).open).not.toHaveBeenCalled() + }) + + it('leaves ordinary same-tab, Shift-window, Alt, and unsafe links to Chromium', () => { + const sameTab = document.createElement('a') + sameTab.href = 'https://example.com/current' + const shifted = document.createElement('a') + shifted.href = 'https://example.com/window' + shifted.target = '_blank' + const altClicked = document.createElement('a') + altClicked.href = 'https://example.com/download' + altClicked.target = '_blank' + const unsafe = document.createElement('a') + unsafe.href = 'javascript:void(0)' + unsafe.target = '_blank' + document.body.append(sameTab, shifted, altClicked, unsafe) + installRouting() + + expect(clickLink(sameTab).open).not.toHaveBeenCalled() + expect(clickLink(shifted, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(altClicked, { altKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(unsafe).open).not.toHaveBeenCalled() + }) + + it('updates an existing isolated-world installation without adding another listener', () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installBrowserClickedLinkRouting('__orca_clicked_link_old_fg', true, true) + installBrowserClickedLinkRouting('__orca_clicked_link_new_fg', false, true) + + expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1) + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + '__orca_clicked_link_new_fg' + ) + }) + + it('builds a self-contained isolated-world script', () => { + const script = buildBrowserClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).not.toContain('BrowserClickedLinkRoutingState') + }) + + it('routes plain iframe target=_blank links into the top-level guest', () => { + const link = document.createElement('a') + link.href = 'https://example.com/from-frame' + link.target = '_blank' + document.body.append(link) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_top') + }) + + it('routes explicit iframe new-tab gestures through one-use frame names', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + document.body.append(blank) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + }) + + it('leaves other-platform, Shift-window, and ordinary iframe clicks to Chromium', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + const ordinary = document.createElement('a') + ordinary.href = 'https://example.com/current' + document.body.append(blank, ordinary) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(ordinary).open).not.toHaveBeenCalled() + }) + + it('ignores synthetic main-frame and iframe clicks in production mode', () => { + const mainLink = document.createElement('a') + mainLink.href = 'https://example.com/main-synthetic' + const frameLink = document.createElement('a') + frameLink.href = 'https://example.com/frame-synthetic' + frameLink.target = '_blank' + document.body.append(mainLink, frameLink) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + + expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(frameLink).open).not.toHaveBeenCalled() + expect(frameLink.getAttribute('target')).toBe('_blank') + }) + + it('builds a self-contained iframe script with per-frame routing tokens', () => { + const script = buildBrowserIframeClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserIframeClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + }) +}) diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts new file mode 100644 index 00000000000..42a8bbf8232 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -0,0 +1,217 @@ +export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208 + +type BrowserClickedLinkRoutingState = { + frameName: string + isMac: boolean + allowUntrustedEvents: boolean + listener: (event: MouseEvent) => void +} + +type BrowserClickedLinkRoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: BrowserClickedLinkRoutingState +} + +/** + * Re-expresses explicit new-tab link gestures with private frame names so main + * can distinguish them from opener-dependent window.open calls. + */ +export function installBrowserClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): void { + const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal + const existing = routingGlobal.__orcaBrowserClickedLinkRouting + if (existing) { + existing.frameName = frameName + existing.isMac = isMac + existing.allowUntrustedEvents = allowUntrustedEvents + return + } + + const state: BrowserClickedLinkRoutingState = { + frameName, + isMac, + allowUntrustedEvents, + listener: () => {} + } + state.listener = (event) => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !state.allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = state.isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = state.isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier) { + return + } + // Shift alone is browser-native new-window intent; keep OAuth and other + // opener-dependent window flows in Orca's guarded popup window. + if (event.shiftKey && !modifierClick) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: changing only the browsing context keeps Chromium's native anchor + // navigation, including referrer policy, attribution, and history. + link.setAttribute('target', '_self') + return + } + + // Why: Electron reports direct link clicks and featureless window.open() + // with the same disposition. The private frame name preserves that one + // distinction without weakening OAuth popups that need window.opener. + event.preventDefault() + window.open(targetUrl.toString(), state.frameName) + } + routingGlobal.__orcaBrowserClickedLinkRouting = state + + // Why: page click handlers must get the first chance to cancel or rewrite a + // link; capture-phase interception breaks SPA routing and analytics handlers. + window.addEventListener('click', state.listener, false) + window.addEventListener('auxclick', state.listener, false) +} + +/** + * Keeps plain target=_blank clicks inside the top-level guest when Electron's + * isolated-world API cannot target a child frame. + */ +export function installBrowserIframeClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): () => void { + const listener = (event: MouseEvent): void => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier || (event.shiftKey && !modifierClick)) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: WebContents isolated worlds only cover the main frame. Rewriting + // to `_top` preserves native anchor semantics without opening a popup. + link.setAttribute('target', '_top') + return + } + + // Why: child-frame code runs in the page world, so each token is one-use. + // A page that observes a real click cannot replay it to create more tabs. + event.preventDefault() + cleanup() + window.open(targetUrl.toString(), frameName) + } + + const cleanup = (): void => { + window.removeEventListener('click', listener, false) + window.removeEventListener('auxclick', listener, false) + } + window.addEventListener('click', listener, false) + window.addEventListener('auxclick', listener, false) + return cleanup +} + +export function buildBrowserClickedLinkRoutingScript(frameName: string, isMac: boolean): string { + return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} + +export function buildBrowserIframeClickedLinkRoutingScript( + frameName: string, + isMac: boolean +): string { + return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 29d2c8f8edd..912923fc8f5 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -250,6 +250,165 @@ describe('browserManager', () => { expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) + const guest = { + id: 141, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: executeJavaScriptInIsolatedWorldMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const domReadyHandler = guestOnMock.mock.calls.find(([event]) => event === 'dom-ready')?.[1] as + | (() => void) + | undefined + domReadyHandler?.() + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1)) + + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map
El orquestador de IA para desarrolladores 100x. - Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar. + Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
100x ビルダーのための AI オーケストレーター。 - Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。 + Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
100x 빌더를 위한 AI 오케스트레이터. - Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다. + Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
面向 100x 构建者的 AI 编排器。 - 并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。 + 并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。