Merge remote-tracking branch 'origin/main' into brennanb2025/sta5878-orphan-kill

# Conflicts:
#	src/main/runtime/orca-runtime.ts
This commit is contained in:
Merge Sim
2026-08-31 23:31:51 -07:00
1028 changed files with 136615 additions and 106536 deletions
+3
View File
@@ -131,6 +131,9 @@ jobs:
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Enforce ts-nocheck ratchet
run: pnpm run check:ts-nocheck-ratchet
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
+68 -25
View File
@@ -1122,10 +1122,33 @@ jobs:
retention-days: 7
if-no-files-found: ignore
# Why: artifact jobs submit Windows binaries to SignPath. Keep every
# quota-consuming build behind all blocking release gates so a late test
# failure cannot create signing requests that can never be published.
release-preflight:
needs:
- cut
- terminal-rendering-golden
- skill-sharing-release-gate
- skill-sharing-linux-floor-release-gate
if: >-
always() &&
needs.cut.outputs.should_release == 'true' &&
needs.terminal-rendering-golden.result == 'success' &&
needs.skill-sharing-release-gate.result == 'success' &&
needs.skill-sharing-linux-floor-release-gate.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Confirm blocking release gates passed
run: echo "All blocking release gates passed; artifact builds may start."
build:
needs:
- cut
- create-release
- release-preflight
if: needs.cut.outputs.should_release == 'true'
strategy:
fail-fast: false
@@ -1170,12 +1193,22 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# GitHub reruns also resume jobs skipped behind a failed gate. Never
# recreate Windows signing requests on a rerun; reuse the assets from the
# original attempt and require a fresh dispatch if they are missing.
- name: Skip Windows artifact rebuild on rerun
if: matrix.platform == 'win' && github.run_attempt != 1
shell: bash
run: |
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
@@ -1321,6 +1354,7 @@ jobs:
# otherwise undecodable. The main bundle is platform-independent, so one
# leg publishes the maps for the whole release.
- name: Bundle main-process source maps
id: bundle-main-sourcemaps
if: matrix.platform == 'linux-x64'
shell: bash
env:
@@ -1328,9 +1362,17 @@ jobs:
run: |
set -euo pipefail
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
echo "::error::No main-process source maps in out/main. Did build.sourcemap regress in electron.vite.config.ts?"
exit 1
# Older cut tags predate the hidden-source-map build setting. They
# are valid legacy releases, but have no map bundle to publish.
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
exit 1
fi
echo "has_maps=false" >>"$GITHUB_OUTPUT"
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
exit 0
fi
echo "has_maps=true" >>"$GITHUB_OUTPUT"
# Why: every entry in electron-builder's `files` is a negation, so
# app-builder prepends `**/*` and packs anything left in the workspace
# root into app.asar. Stage the bundle outside the checkout instead.
@@ -1338,7 +1380,7 @@ jobs:
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
- name: Publish main-process source maps
if: matrix.platform == 'linux-x64'
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
@@ -1373,7 +1415,7 @@ jobs:
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
# upload only after the production-signed installer has been returned.
- name: Build Windows release artifacts
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
@@ -1384,7 +1426,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify Windows node-pty ConPTY runtime
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
@@ -1401,7 +1443,7 @@ jobs:
}
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: ./.github/actions/install-signpath-module
# ── Windows inner-binary signing (issue #7785) ─────────────────────
@@ -1418,7 +1460,7 @@ jobs:
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
continue-on-error: true
shell: pwsh
run: |
@@ -1457,7 +1499,7 @@ jobs:
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
@@ -1467,7 +1509,7 @@ jobs:
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
@@ -1481,7 +1523,7 @@ jobs:
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
@@ -1549,7 +1591,7 @@ jobs:
# falls through to today's unsigned-inner flow rather than blocking.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
@@ -1572,7 +1614,7 @@ jobs:
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1613,7 +1655,7 @@ jobs:
# unsigned again, which the evidence gate will flag.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1640,7 +1682,7 @@ jobs:
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1657,7 +1699,7 @@ jobs:
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
@@ -1667,7 +1709,7 @@ jobs:
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
@@ -1679,7 +1721,7 @@ jobs:
# so the release job waits while the signing request is approved in UI.
- name: Submit Windows installer signing request
id: submit-signing-request
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1691,7 +1733,7 @@ jobs:
wait-for-completion: false
- name: Notify Slack that Windows signing is waiting for approval
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -1755,7 +1797,7 @@ jobs:
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
- name: Download signed Windows installer from SignPath
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1773,7 +1815,7 @@ jobs:
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
- name: Stage signed Windows release assets
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
@@ -1810,7 +1852,7 @@ jobs:
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
- name: Verify signed Windows installer
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
@@ -1828,7 +1870,7 @@ jobs:
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
@@ -1960,7 +2002,7 @@ jobs:
if ($policyFailure) { throw $policyFailure }
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
if: always() && matrix.platform == 'win' && github.run_attempt == 1
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
@@ -1971,7 +2013,7 @@ jobs:
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
@@ -2026,6 +2068,7 @@ jobs:
needs:
- cut
- create-release
- release-preflight
if: needs.cut.outputs.should_release == 'true'
# Why: SignPath requires every job in this signing workflow to be
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
+6
View File
@@ -76,6 +76,12 @@ When adding or changing a Git command:
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
## Git Scan Safety
- Never enumerate every ref and then run `git ls-tree -r` or `git show` once per ref. That ref × tree fan-out can retain gigabytes of output before a downstream `sort -u` or search can make progress.
- Prefer `rg` over the checked-out files for source searches. For history or refs, use a named ref, an explicit namespace/path, `--max-count`, and a bounded output; do not use an unqualified `--all` scan as a first diagnostic.
- Keep repository-wide commands targeted to the current repository and worktree. If an unbounded scan is genuinely required, measure the ref count first, explain the cost, and get confirmation before running it.
## Git Provider Compatibility
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
+2 -3
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
@@ -262,7 +262,6 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
</p>
## Signed Builds
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
-4
View File
@@ -11,10 +11,6 @@ inline src/main/index.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
+295 -13
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-23",
"updatedAt": "2026-08-31",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -5722,6 +5722,284 @@
],
"demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization."
},
{
"id": "terminal-session.split-activation-ordering",
"title": "Terminal splits activate before inherited-CWD lookup settles",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-renderer-lifecycle",
"layer": "renderer-unit-and-local-transport",
"surfaces": [
"terminal pane split",
"inherited working directory",
"pre-connect terminal input",
"split close cleanup",
"pre-bind pane detach"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "remote-runtime"],
"coverageNotes": "Deterministic renderer contracts hold CWD resolution behind an explicit promise, require the new pane to be created synchronously, and prove that close cancels the pending connection. A module-level stable-pane-key handoff preserves the exact CWD promise and bounded pre-connect input across whole-tab remounts, including a tab rehome between worktree buckets; stale owners are fenced, concrete PTY bind and definitive spawn failure clear the record, and explicit pane close discards it. Detach contracts reject cwd-pending and cwd-resolved deferred splits before PTY bind without mutation, carry resolved cwd for other unbound panes, and preserve persisted or live PTY handoff. Local IPC transport contracts exercise the real bounded pre-connect buffer and one live input FIFO across seeded and newly typed ordinary, acknowledged, and immediate writes, concurrent flushes, in-flight teardown, late spawn success or failure, attach failure, failed spawn, same-id reuse, stale-spawn retirement ownership, destroy, and mutable recovery metadata. The handoff registry is capped at 64 records for 15 seconds and shares the existing 1,024-entry/conservative UTF-16 input ceilings. A mocked direct-SSH authority-rotation contract proves a rejected stale spawn releases its deferred-CWD fence. The schema-v2 headful Electron benchmark records exact revision identity and attributes CWD request/settlement, PTY spawn request/result, bind, fixture unlock request/IPC write, fixture readiness, input, and first echo across 3 warmups and 20 measured cold-CWD cycles, requiring a distinct child PTY and observed child pty:exit before the next cycle. Remote-runtime coverage proves delegation remains host-owned; its host-delegated split path does not consume the local pre-connect input options, so remote-runtime input-remount replay and physical local-daemon, SSH, WSL, Linux, Windows, and folder-workspace latency journeys remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/commit/572ed1a8882"],
"invariant": "A terminal split creates and activates its renderer pane before an inherited-CWD lookup settles, starts its PTY only after the resolved directory is available, and cannot be externally detached while that deferred spawn remains unbound. A whole-tab remount or worktree rehome preserves the same stable pane's CWD promise and admitted local pre-connect bytes in order until a successor binds or the intent is definitively abandoned; stale owners cannot append or clear the successor's record. Other unbound panes preserve resolved cwd as startupCwd when detached. Bounded pre-connect input and later live local input share byte order, and teardown settles acknowledged writes without creating or rebinding a stale PTY. A disconnected or detached pending connect cannot bind its late fresh spawn, report its late failure through current callbacks, or ID-retire a newer same-ID owner; rejecting a stale direct-SSH spawn also releases the matching deferred-CWD fence. Natural exit cannot deliver queued work into a reused PTY id. Bound and remote-runtime splits remain owned by their execution host.",
"oracle": "Hold CWD resolution behind a controllable promise, invoke the production split path, and require manager.splitPane plus split telemetry before resolving it. Before PTY bind, require both cwd-pending and cwd-resolved deferred detach attempts to return null without layout, pane, tab, ownership, or focus mutation; separately require resolved cwd on an allowed unbound detach and unchanged persisted/live PTY adoption. Exercise the stable-pane handoff registry through repeated remounts and a worktree rehome, requiring the identical CWD promise, ordered ordinary/acknowledged/immediate seed replay, stale-owner fencing, 64-record/15-second bounds, and discard on bind, failure, or explicit close. Rotate a mocked direct-SSH authority while its delayed spawn is in flight, reject and disconnect the stale PTY claim, then require exactly one deferred-CWD cleanup when the delayed connect settles. At the local IPC PTY boundary, require zero connect calls while pending, the resolved CWD in spawn and local recovery metadata, one shared FIFO across seeded/new pre-connect and live ordinary/acknowledged/immediate input, prompt predecessor acknowledged-promise settlement on teardown, retirement of an unowned late fresh spawn, preservation of a newer same-ID owner, and zero stale delivery after failure, close, destroy, detach, natural exit, or same-id reuse. Capture callback exceptions must not change admission results. In visible Electron, press the real split shortcut for 3 warmup cycles, then after a cold inherited-CWD interval for each of 20 measured cycles, require an exact clean revision identity, complete focus/CWD/spawn/bind/fixture/input/echo attribution, distinct child PTYs, pane count, and child exits for every cycle; a timed-out, missing-event, or cleanup-aborted run must publish no headline latency and fail.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
// Historical evidence record retained so its seven-file command remains auditable.
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
// Historical evidence record retained so its nine-file command remains auditable.
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
// Historical schema-v1 evidence records only; their labels do not verify the checkout or harness.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
// Exact-HEAD schema-v1 evidence records use the committed harness but predate revision metadata.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
// Schema-v2 evidence embeds the exact checkout identity and clean/dirty state.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
"src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
"src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
"tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
"tests/e2e/terminal-split-activation-latency-main-probe.ts",
"tests/e2e/terminal-split-activation-latency-phases.ts",
"tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
"tests/e2e/terminal-split-activation-latency.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
"assertions": [
"creates and records the split before pending CWD resolution",
"passes the resolved CWD promise without reviving a stale manager",
"rapid nested splits reuse one pending CWD lookup",
"keeps remote-runtime split ownership on its execution host"
]
},
{
"file": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
"assertions": ["focuses the new pane before publishing an unresolved CWD spawn hint"]
},
{
"file": "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
"assertions": [
"preserves a deferred split fence when OSC 7 updates cwd",
"clears a settled deferred entry only for matching promise identity",
"keeps a newer deferred lookup when an older cleanup callback arrives"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
"assertions": [
"starts no PTY connection before inherited CWD resolves",
"applies the resolved directory to transport options",
"disposing the split before resolution cancels the pending spawn",
"invokes deferred-CWD cleanup exactly once after an authority-rotated direct-SSH spawn is disconnected and its delayed connect settles"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"assertions": [
"rejects a deferred split while inherited CWD is pending without mutation",
"continues rejecting after CWD resolves until PTY bind",
"carries resolved CWD as startupCwd for an allowed unbound detach",
"preserves persisted and live remote PTY detach handoff"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
"assertions": [
"concurrent flush calls share one worker and preserve mixed input order",
"clear settles an in-flight acknowledged write before its late resolve or reject",
"in-flight acknowledged input remains charged to entry and code-unit caps"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"assertions": [
"ordinary, acknowledged, and immediate pre-connect input flushes in byte order",
"pending acknowledged input settles on connect, destroy, and spawn failure",
"disconnect, destroy, and natural exit cancel an in-flight acknowledged write without blocking connect",
"live acknowledged input blocks later ordinary and immediate writes at its invocation position",
"the preconnect-to-live transition preserves the same input FIFO",
"disconnect and detach retire a late fresh spawn and suppress late failures before they reach current callbacks",
"natural exit fences queued ordinary and acknowledged chunks across same-id reuse",
"buffered exit and attach failure clear retained input",
"ordinary and acknowledged write failures drop later input without leaving promises pending",
"entry and code-unit ceilings bound pre-connect input retention",
"local recovery metadata observes the resolved split CWD",
"a stale fresh-spawn completion cannot retire a newer same-ID owner"
]
},
{
"file": "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
"assertions": [
"hands the same cwd promise and buffered input to a remounted leaf in order",
"keeps input across repeated remounts and fences stale owners",
"releases an unmounted owner without dropping its pending handoff",
"retains input within the shared preconnect entry and code-unit caps",
"evicts the oldest handoff when the record cap is reached",
"expires an abandoned handoff after the bounded remount window"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
"assertions": [
"writes a passing benchmark report to the requested artifact path",
"fails when the benchmark artifact path cannot be written"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-main-probe.ts",
"assertions": [
"attributes CWD and PTY spawn request/settlement events to the source and child PTYs",
"captures the fixture unlock carriage return on both ordinary and acknowledged IPC channels",
"restores the intercepted IPC handlers and listener when the probe is disposed"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-phases.ts",
"assertions": [
"merges main-process events by operation and PTY identity without cross-cycle attribution",
"requires every activation, fixture, input, echo, pane, PTY, and cleanup observation for success",
"reports each attributed phase distribution with non-negative cross-clock durations"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
"assertions": [
"attributes main-process phases to the matching source and child PTYs",
"embeds schema-v2 revision identity and summarizes the attributed phases",
"invalidates a sample when the actual fixture-unlock IPC write is missing"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency.spec.ts",
"assertions": [
"requires a visible BrowserWindow and visible document before sampling",
"records schema-v2 revision identity plus attributed CWD, spawn, bind, fixture-ready, input, and echo phases",
"records 3 warmups, then 20 measured real-shortcut cycles after cold inherited-CWD intervals",
"requires every split to focus, bind a PTY distinct from its source, and echo immediate input",
"observes each closed child PTY exit before starting the next cycle",
"publishes headline latency only for a fully successful 3-warmup/20-measured run"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
"durationSeconds": 56.59,
"summary": "Seven focused files and 78 tests passed. Vitest reported 49.92 seconds and the measured wall time was 56.59 seconds; coverage includes split creation and focus ordering, nested CWD lineage, promise-identity and SSH authority-rotation cleanup fencing, full pre-bind detach fencing, resolved-CWD detach handoff, close-cancellation, single-FIFO ordering, late-spawn retirement and error suppression, preservation of a newer same-ID owner, generation fencing, in-flight settlement across explicit teardown and natural exit, attach cleanup, bounded retention, and existing input-write contracts."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
"durationSeconds": 44.43,
"summary": "Nine focused files and 96 tests passed. Vitest reported 37.78 seconds and measured wall time was 44.43 seconds; the run adds stable-pane CWD/input handoff, repeated-remount stale-owner fencing, bounded 64-record/15-second retention, seeded-input caps, ordered ordinary/acknowledged/immediate replay, predecessor acknowledged-promise settlement, capture-callback failure containment, and benchmark-artifact write-failure coverage to the existing split, detach, CWD, and local transport contracts."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
"durationSeconds": 4.14,
"summary": "The updated twelve-path focused command passed 99 tests (10 runnable test files plus 2 benchmark support modules), including schema-v2 main-process phase attribution, report revision identity, fixture IPC-write validation, stable-pane handoff, ordered pre-connect/live input, cleanup, detach, failure, and same-ID ownership contracts."
},
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At baseline df14d1a2983d8339e788d0e521f1c4affd9c6d5f, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 65.7/88.7/102.6 ms, PTY bind was 122.8/154.0/159.7 ms, and first echo was 203.8/264.2/332.7 ms. Artifact SHA-256: 6d860cd0cd210f55f2349a197318248af488042117b20c08b6831160950c3277."
},
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At candidate d453ffcdb704764daced1b2917fddee7224389f0, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.4/16.5 ms, PTY bind was 177.0/316.1/333.3 ms, and first echo was 268.6/627.4/710.7 ms. Artifact SHA-256: 9aef7fa842c732eb74f0066a77b2a0336e8f956a06ca61387f9999d6e76213cc."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.6/13.7/13.7 ms, PTY bind was 140.5/399.1/464.1 ms, and first echo was 192.0/519.3/2343.1 ms. Artifact SHA-256: 875e9d37dc711472a81438e4bbdbc8cbc7aada8c961d14195c024d8da350b9e2."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.0/14.3 ms, PTY bind was 236.5/654.0/687.3 ms, and first echo was 566.8/1191.5/1219.7 ms. Artifact SHA-256: 4f66b93e5c936ade05f880010f4ec027385d5c89893430169af91c7fdfbe1d06."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 87.6,
"summary": "At exact clean HEAD 073e6c7b0eb1c0ccbb115db1528b641901997c73, the schema-v2 artifact records dirty=false, a visible BrowserWindow/document, and 3/3 warmups plus 20/20 measured cycles with every missing-event counter at zero, distinct child PTYs, and observed child exits. Measured focus p50/p95/max was 12.0/13.6/14.7 ms; attributed CWD lookup was 40/97/103 ms, CWD-settle to spawn request 1/4/4 ms, spawn request to result 48/120/122 ms, and spawn result to bind 2.1/2.5/2.5 ms. Fixture unlock request to IPC write was 0.5/0.8/0.9 ms, IPC write to fixture-ready parse was 35.1/87.4/141.8 ms, and input to first echo was 1.3/3.5/3.9 ms. Total shortcut-to-bind was 113.5/161.3/162.7 ms and shortcut-to-first-echo was 158.5/240.7/291.2 ms. Artifact SHA-256: 1e7ff9e658b717056273d64ecdf662cc6b5776bb6dae1827ed213b7647e4a5fb."
}
],
"evidenceProcedure": "Run the benchmark spec in a visible macOS Electron project from a clean primary worktree, complete 3 warmups followed by 20 measured cold-CWD cycles, require zero missing events and successful cleanup, save the schema-v2 JSON report, and record its SHA-256 plus embedded revision identity. The four older records are schema-v1 historical audit records whose labels do not verify the checkout or include phase attribution; the clean schema-v2 record is the current candidate evidence. A paired baseline/final rerun with one revision-verifying harness remains required before promotion or a readiness comparison claim.",
"runtimeBudget": {
"p95Seconds": 240,
"scope": "the full listed gate command set: one current twelve-path focused invocation (ten runnable test files plus two benchmark support modules), one historical nine-file unit invocation, one historical seven-file unit invocation, and five opt-in 3-warmup/20-measured visible Electron benchmark invocations (four schema-v1 historical records plus one clean schema-v2 record)"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The focused promise-barrier, remount-handoff, benchmark-artifact, and schema-v2 attribution suite passes locally in 99 tests; the clean visible benchmark passes 3/3 warmups and 20/20 measured cycles with zero missing events. Its first attempt hit a transient warmup cleanup-dialog click timeout, then the exact command passed on retry without a launch, profile, or port workaround. Routed CI and soak history have not started. Historical benchmark labels do not verify the product checkout or embed revision identity."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Before the production seam landed, the split assertion failed with zero manager calls while CWD was pending, and the transport assertion rejected the first pre-connect input. Before the detach fence, a deferred split could be removed after CWD resolved but before PTY bind, dropping its pre-connect input. Before the single-flight hardening, the concurrent-flush oracle delivered ordinary input before the earlier acknowledged write and clear left the in-flight promise pending. Before stale-spawn ownership fencing, the combined deferred-connect and newer same-ID attach fixture called kill on the current PTY. An intentional one-line revert of deferred-CWD cleanup in the stale direct-SSH claim branch failed its focused callback assertion with zero calls instead of one; restoring it passed the prior focused tests. The remount-handoff, transport, artifact-write, and schema-v2 attribution regressions are green in the 99-test twelve-path run, but isolated intentional-revert evidence for each cleanup branch remains outstanding."
},
"performanceBudget": {
"required": true,
"evidence": "Pane creation and focus add no timer, polling, provider inventory, or subprocess work. CWD resolution remains one existing bounded request off the visible activation path, and detach admission adds only bounded map and record lookups. The remount handoff adds one module-level map lookup per pane lifecycle, a 64-record cap, and a 15-second expiry; it retains no unbounded payload. Pre-connect input, including an in-flight acknowledged write and remount seed replay, is capped at 1,024 entries and a conservative UTF-16 ceiling derived from the existing terminal-input byte limit, drains through one worker in order, and clears on teardown or failed connect. The historical schema-v1 same-mode pair recorded shortcut-to-focus p50/p95/max changing from 65.7/88.7/102.6 ms to 12.8/14.4/16.5 ms; those labels do not embed revision identity, so the comparison is directional evidence only. The clean schema-v2 candidate attributes focus at 12.0/13.6/14.7 ms while CWD lookup takes 40/97/103 ms and spawn request-to-result takes 48/120/122 ms, demonstrating that provider/process startup follows activation rather than blocking it. In that clean run, shortcut-to-bind is 113.5/161.3/162.7 ms, fixture IPC-write-to-ready is 35.1/87.4/141.8 ms, and input-to-echo is 1.3/3.5/3.9 ms; these readiness phases are diagnostic, one-host descriptive measurements, and no clean schema-v2 baseline exists to support a readiness improvement or regression claim. The n=20 empirical p95 values are descriptive, are not a distribution guarantee, and are not CI-enforced."
},
"promotionCriteria": [
"Record complete red/green evidence for close, remount/rehome handoff, mixed-input ordering, metadata, and failure cleanup.",
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
"Run the committed real-shortcut Electron benchmark in routed CI or soak before enforcing a latency budget.",
"Collect physical local-daemon, SSH or WSL plus Linux, Windows, and folder-workspace evidence before claiming provider-complete coverage."
],
"knownGaps": [
"The clean schema-v2 candidate run and the historical schema-v1 comparison records ran on one Apple-silicon macOS host with a synthetic POSIX echo shell and a git-backed workspace; their n=20 empirical p95 values are descriptive and not CI-enforced.",
"No physical local-daemon, SSH, WSL, Linux, Windows, or folder-workspace latency journey has run; the synthetic fixture is currently skipped on Windows because it requires a POSIX shell.",
"Remote-runtime split creation remains host-delegated and its transport does not consume the local pre-connect seed/capture options; CWD handoff is covered, but remote-runtime pre-connect input replay has no implementation or evidence.",
"The four stored schema-v1 artifacts predate the final harness attribution/reporting and do not embed revision identity; the clean schema-v2 candidate artifact is revision-verified, but both product revisions still need a paired schema-v2 rerun with one committed harness before promotion.",
"No forced-failure visible benchmark artifact has been recorded; the focused artifact-write and missing-event report contracts verify local failure handling, while failure-report serialization remains unverified by a full visible run.",
"No clean schema-v2 baseline phase artifact exists, so the attributed CWD, spawn, fixture-ready, bind, and echo timings diagnose where time is spent but do not establish a shell-readiness improvement or regression."
],
"demotionRule": "Keep experimental or demote if pane activation waits on CWD, a deferred split can detach before PTY bind, a remount or rehome loses its stable CWD/input handoff, stale owners mutate a successor record, detached cwd is lost, input reorders or remains pending after cleanup, a closed pane can spawn, stale retirement kills a newer same-ID owner, remote-runtime delegation creates a competing local pane, or the focused suite flakes without an identified product or harness cause."
},
{
"id": "terminal-session.kill-all-surface-cleanup",
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
@@ -8194,9 +8472,7 @@
"assertionRefs": [
{
"file": "tests/e2e/persisted-session-production-upgrade.spec.ts",
"assertions": [
"upgrades a legacy daemon session and keeps it stable after relaunch"
]
"assertions": ["upgrades a legacy daemon session and keeps it stable after relaunch"]
}
],
"evidenceRuns": [
@@ -13970,14 +14246,14 @@
"providers": ["local", "daemon", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, acknowledged-write FIFO barriers, single-worker drain reentrancy, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, teardown settlement, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13137",
"https://github.com/stablyai/orca/issues/7329",
"https://github.com/stablyai/orca/issues/13892"
],
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and ordinary, acknowledged, and reply input share one invocation-ordered FIFO so no later write overtakes an acknowledged write. Reentrant write callbacks cannot start a second drain worker or strand input admitted after clear/reuse. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and failure or teardown cannot clear a newer queue generation or strand an acknowledged promise.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Stall an acknowledged write between earlier and later ordinary/reply input, then require teardown to settle it and same-id reuse to receive no stale tail. Reenter the queue synchronously from an acknowledged write with both enqueue and clear/reuse, requiring one drain and fresh input delivery only after the stale acknowledged write settles false. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
@@ -14009,14 +14285,20 @@
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
"retained OSC, DA1, and CPR replies stay one provider write each and both OSC and DA1 floods remain bounded",
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
"clear releases saturated reply accounting and fences in-flight validation before later input"
"clear releases saturated reply accounting and fences in-flight validation before later input",
"acknowledged input is serialized between earlier and later ordinary/reply writes",
"clear settles active and pending acknowledged input before same-id queue reuse",
"reentrant enqueue cannot start a second drain worker past an unacknowledged write",
"reentrant clear captures the stale cancellation and continues draining fresh input"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"assertions": [
"sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order",
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation",
"live and preconnect acknowledged writes remain FIFO barriers for later ordinary and immediate input",
"disconnect, detach, and natural exit settle acknowledged writes and fence same-id stale chunks"
]
},
{
@@ -14072,13 +14354,13 @@
],
"evidenceRuns": [
{
"date": "2026-08-25",
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"result": "passed",
"durationSeconds": 1.05,
"summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
"durationSeconds": 15,
"summary": "Five files and 149 tests passed in 15.16 seconds, including explicit IPC reply-source routing, the 10,000-reply count and text ceilings, 10,000-entry ordinary backlog preservation, acknowledged-write FIFO barriers, synchronous reentrancy fencing, prompt teardown settlement, same-id generation fencing, real xterm OSC query floods, single-shot drain-failure recovery, and one-reply-per-write echo containment."
},
{
"date": "2026-08-09",
@@ -14127,7 +14409,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet."
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. Before reentrancy fencing, a synchronous accepted-write callback started a second drain that falsely accepted the pending write; clear/reuse also captured the replacement generation's cancellation and stranded fresh input. No saved intentional-break artifact is attached yet."
},
"performanceBudget": {
"required": true,
@@ -95,6 +95,66 @@ describe('benchmark artifact comparison', () => {
})
})
it('compares terminal split headline metrics in milliseconds', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'split-baseline.json', {
label: 'split baseline',
headlineMs: {
shortcutToFocusP50: 284.2,
shortcutToFocusP95: 676.3
}
})
const candidatePath = writeArtifact(dir, 'split-candidate.json', {
label: 'split candidate',
headlineMs: {
shortcutToFocusP50: 12.7,
shortcutToFocusP95: 13.7
}
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.baseline.kind).toBe('terminal-split-activation')
expect(comparison.metrics).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'shortcutToFocusP50',
unit: 'ms',
baseline: 284.2,
candidate: 12.7,
status: 'improved'
}),
expect.objectContaining({
key: 'shortcutToFocusP95',
unit: 'ms',
baseline: 676.3,
candidate: 13.7,
status: 'improved'
})
])
)
})
it('rejects invalid benchmark artifacts before comparing partial metrics', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'split-invalid.json', {
label: 'invalid split',
status: 'failed',
valid: false,
headlineMs: { shortcutToFocusP50: 0 }
})
const candidatePath = writeArtifact(dir, 'split-valid.json', {
label: 'valid split',
status: 'passed',
valid: true,
headlineMs: { shortcutToFocusP50: 10 }
})
expect(() => comparePaths(baselinePath, candidatePath)).toThrow(
'split-invalid.json: benchmark artifact is marked invalid'
)
})
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
+230
View File
@@ -0,0 +1,230 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
// Ratchet gate for the `@ts-nocheck` directive.
//
// TypeScript only honours `@ts-nocheck` in a comment before the first statement, and
// once present it disables type checking for the ENTIRE file. PR #17605 split a single
// 43,928-line class into ~172 modules whose linear mixin-inheritance chain cannot yet
// express forward references, so each carries a grandfathered `@ts-nocheck` header. This
// check freezes that set (the baseline) and fails CI when a NEW file adds the directive —
// the existing files are grandfathered; new ones must fix their types instead. The
// baseline may only shrink.
const BASELINE_PATH = 'config/ts-nocheck-baseline.txt'
// These two files legitimately contain the directive text as data (regex, fixtures),
// so scanning them would self-flag. The ratchet does not police itself.
const SELF_FILES = new Set([
'config/scripts/check-ts-nocheck-ratchet.mjs',
'config/scripts/check-ts-nocheck-ratchet.test.mjs'
])
// True if `@ts-nocheck` appears in a comment before the first statement, matching the
// TypeScript rule. Limitation: only the leading run of blank lines / line comments /
// block comments at the top of the file is scanned, so a directive-looking string deeper
// in a block comment that itself starts at the top is still checked — but anything after
// real code (or inside a string literal, which never opens the leading comment run) is not.
export function hasTsNoCheck(sourceText) {
let i = 0
const n = sourceText.length
while (i < n) {
const rest = sourceText.slice(i)
const blank = /^[ \t]*\r?\n/.exec(rest)
if (blank) {
i += blank[0].length
continue
}
if (rest.startsWith('//')) {
const end = sourceText.indexOf('\n', i)
const line = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end)
if (/^\/\/\s*@ts-nocheck\b/.test(line)) {
return true
}
i = end === -1 ? n : end + 1
continue
}
if (rest.startsWith('/*')) {
const end = sourceText.indexOf('*/', i + 2)
const block = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end + 2)
if (/^\/\*\s*@ts-nocheck\b/.test(block)) {
return true
}
i = end === -1 ? n : end + 2
continue
}
break
}
return false
}
export function parseBaseline(text) {
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
export function diffBaseline(current, baseline) {
const cur = new Set(current)
const base = baseline instanceof Set ? baseline : new Set(baseline)
const added = [...cur].filter((e) => !base.has(e)).sort()
const stale = [...base].filter((e) => !cur.has(e)).sort()
return { added, stale }
}
// Collect every currently tracked file that carries a `@ts-nocheck` header.
export function collectCurrentTsNoCheckFiles(root = process.cwd()) {
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter(Boolean)
.filter((f) => !SELF_FILES.has(f))
const entries = []
for (const rel of tracked) {
let src
try {
src = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
continue
}
if (hasTsNoCheck(src)) {
entries.push(rel)
}
}
return entries.sort()
}
function printAddedFailure(added) {
for (const entry of added) {
console.error(`::error::New @ts-nocheck not allowed: ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`)
console.error('')
for (const entry of added) {
console.error(` • ${entry}`)
}
console.error('')
console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.')
console.error(
' The grandfathered entries exist only because the split runtime mixin chain cannot'
)
console.error(' express forward references yet — that is not a general license to suppress.')
console.error('')
console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.')
console.error('')
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
console.error('')
}
function printStaleFailure(stale) {
for (const entry of stale) {
console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`)
console.error(
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
)
console.error('')
for (const entry of stale) {
console.error(` • ${entry}`)
}
console.error('')
console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`)
console.error('')
}
export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
if (!fs.existsSync(baselineFile)) {
console.error(
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init`
)
return 1
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const current = collectCurrentTsNoCheckFiles(root)
const { added, stale } = diffBaseline(current, baseline)
if (added.length > 0) {
printAddedFailure(added)
if (stale.length > 0) {
console.error(
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
)
printStaleFailure(stale)
}
return 1
}
if (stale.length > 0) {
printStaleFailure(stale)
return 1
}
console.log(
`ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.`
)
return 0
}
function writeBaseline(root, entries) {
const header = [
'# Files currently allowed to carry a `@ts-nocheck` header.',
'# This is a RATCHET: the list may only SHRINK. These exist only because the split',
'# runtime mixin chain cannot express forward references yet — do NOT add entries to',
'# get CI green; fix the types instead.',
'# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)',
''
].join('\n')
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
const arg = process.argv[2]
if (arg === '--init') {
// One-time bootstrap: capture the current @ts-nocheck set as the baseline.
const entries = collectCurrentTsNoCheckFiles(root)
writeBaseline(root, entries)
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
process.exit(0)
}
if (arg === '--prune') {
// Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds).
const current = new Set(collectCurrentTsNoCheckFiles(root))
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
const kept = [...baseline].filter((e) => current.has(e)).sort()
const newlyAdded = [...current].filter((e) => !baseline.has(e))
writeBaseline(root, kept)
console.log(
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
)
if (newlyAdded.length > 0) {
console.error(
`::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.`
)
process.exit(1)
}
process.exit(0)
}
process.exit(main(root))
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs'
describe('hasTsNoCheck', () => {
it('detects a line-comment form', () => {
expect(hasTsNoCheck('// @ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a block-comment form', () => {
expect(hasTsNoCheck('/* @ts-nocheck */\nexport const a = 1\n')).toBe(true)
})
it('detects the no-space form', () => {
expect(hasTsNoCheck('//@ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a directive with a -- Why reason', () => {
expect(
hasTsNoCheck(
'// @ts-nocheck -- Why: mechanically split, covered by AST tests.\nimport x from "y"\n'
)
).toBe(true)
})
it('allows blank lines and other leading comments before the directive', () => {
const src =
'\n// Copyright notice.\n\n/* another leading comment */\n// @ts-nocheck\nexport const a = 1\n'
expect(hasTsNoCheck(src)).toBe(true)
})
it('does not match once a statement has started', () => {
const src = 'export const a = 1\n// @ts-nocheck\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('does not match inside a string literal', () => {
const src = 'export const a = "// @ts-nocheck"\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('returns false for ordinary source', () => {
expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false)
})
})
describe('parseBaseline', () => {
it('drops comments and blank lines', () => {
const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n')
expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts']))
})
})
describe('diffBaseline', () => {
it('reports added and stale entries', () => {
const { added, stale } = diffBaseline(
['src/b.ts', 'src/c.ts'],
new Set(['src/a.ts', 'src/b.ts'])
)
expect(added).toEqual(['src/c.ts']) // new suppression
expect(stale).toEqual(['src/a.ts']) // suppression removed
})
it('is clean when current matches baseline', () => {
const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts']))
expect(added).toEqual([])
expect(stale).toEqual([])
})
})
+13 -1
View File
@@ -74,6 +74,9 @@ export function readBenchmarkArtifact(path) {
}
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
if (artifact?.valid === false || artifact?.status === 'failed') {
throw new Error(`${path}: benchmark artifact is marked invalid`)
}
if (artifact?.summaryMedianMs != null) {
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
}
@@ -82,6 +85,15 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
)
}
if (artifact?.headlineMs != null) {
return normalizeNumericObject(
path,
artifact,
'terminal-split-activation',
artifact.headlineMs,
() => 'ms'
)
}
if (artifact?.suites != null) {
return normalizePlaywrightArtifact(path, artifact)
}
@@ -89,7 +101,7 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
return normalizeSummaryArtifact(path, artifact)
}
throw new Error(
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, headlineMs, Playwright suites, or top-level summary`
)
}
@@ -12,11 +12,33 @@ const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
describe('computer-use skill guidance', () => {
it('keeps discovery scoped to desktop control and out of the embedded browser', () => {
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(readFileSync(guidePath, 'utf8'))?.[1] ?? ''
const description = frontmatter.replace(/\s+/gu, ' ')
expect(description).toContain('OS/window-level inspection and input')
expect(description).toContain('external browser window')
expect(description).toContain("Do not use for Orca's embedded browser")
expect(description).toContain('page-only browser automation')
expect(description).toContain("`orca-cli` for Orca's embedded pages")
expect(description).toContain(
'page-automation tool such as Playwright or CDP for external pages'
)
expect(description).not.toContain('read Slack')
expect(description).not.toContain('get app state')
const orcaCli = readFileSync(join(projectDir, 'skill-guides', 'orca-cli.md'), 'utf8').replace(
/\s+/gu,
' '
)
expect(orcaCli).toContain('browser embedded inside the Orca app')
})
it('keeps web-app targeting on the computer-use surface', () => {
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
expect(skill).toContain('operate the desktop browser app/window that contains the page')
expect(skill).toContain('external desktop browser window that needs desktop-level control')
expect(skill).not.toContain('orca goto')
expect(skill).not.toContain('orca snapshot')
expect(skill).not.toContain('orca click')
@@ -1,3 +1,25 @@
import { execFileSync } from 'node:child_process'
/** Written once a bundle is fully built; its absence is what marks a build still in flight. */
export const DEV_BUNDLE_MARKER_FILENAME = 'orca-dev-electron-app.json'
export function getDevBundleProcessTable(execFile = execFileSync) {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see isDevBundleInUse for why it is
// deliberately not parsed into paths.
try {
return execFile('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per
// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is
// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed --
+16
View File
@@ -0,0 +1,16 @@
/** Where the Electron executable sits inside a `dist` tree, relative to it. */
export function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -0,0 +1,210 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
/** Matches the fake package version and the platform/arch runInstallScript installs for. */
export const sharedEntryName = '41.5.0-linux-x64'
export const sharedEntryNameFor = (version) => `${version}-linux-x64`
export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
return projectDir
}
export function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
export function writeFakeElectronPackage(
projectDir,
{ lazyRequireMarker = null, version = '41.5.0' } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(join(electronDir, 'package.json'), JSON.stringify({ name: 'electron', version }))
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
export function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
export function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
export function writeFakeExtractor(projectDir, { createExecutable, version = '41.5.0' }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { appendFileSync, mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
appendFileSync(join(__dirname, 'fake-extractor.log'), extractDir + '\\n')
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), ${JSON.stringify(`v${version}`)})
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
export function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
export function initGitRepo(projectDir) {
runGit(projectDir, ['init', '--quiet', '--initial-branch=main'])
runGit(projectDir, ['config', 'user.email', 'orca-test@example.com'])
runGit(projectDir, ['config', 'user.name', 'Orca Test'])
runGit(projectDir, ['commit', '--quiet', '--allow-empty', '-m', 'init'])
}
export function addSiblingWorktree(projectDir, siblingDir) {
runGit(projectDir, ['worktree', 'add', '--quiet', '-b', 'sibling', siblingDir])
copyScriptWithLocalModules(sourceScriptPath, join(siblingDir, 'config', 'scripts'))
return siblingDir
}
function runGit(projectDir, args) {
execFileSync('git', ['-C', projectDir, ...args], { stdio: 'ignore' })
}
export function sharedCacheRoot(repoDir) {
return join(repoDir, '.git', 'orca-cache', 'electron')
}
export function readSharedDistMarker(projectDir) {
try {
return readFileSync(join(projectDir, 'node_modules/electron/.orca-shared-dist'), 'utf8')
} catch {
return null
}
}
export function readExtractorCallCount(projectDir) {
try {
return readFileSync(join(projectDir, 'fake-extractor.log'), 'utf8').trim().split('\n').length
} catch {
return 0
}
}
export function writeNonDarwinPlatformPreload(projectDir) {
const preloadPath = join(projectDir, 'non-darwin-platform.cjs')
writeFileSync(
preloadPath,
`
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
`
)
return preloadPath
}
@@ -15,6 +15,14 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
@@ -54,7 +62,18 @@ try {
async function main() {
repairElectronPathFile()
const sharedEntry = resolveSharedElectronDistEntry({
repoRoot: projectDir,
electronPackageDir,
version: electronVersion,
targetPlatform,
targetArch
})
if (electronPackageIsUsable()) {
if (sharedEntry !== null && !hasAdoptedSharedElectronDist(sharedEntry)) {
shareExistingElectronDist(sharedEntry)
}
return
}
@@ -62,7 +81,7 @@ async function main() {
// Node. Install only Electron's npm package binary here; do not run the full
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
console.log('[electron-package] Electron package binary is missing; running Electron install.')
await installElectronPackageBinary()
await installElectronPackageBinary(sharedEntry)
repairElectronPathFile()
@@ -122,8 +141,11 @@ function repairElectronPathFile() {
}
}
async function installElectronPackageBinary() {
async function installElectronPackageBinary(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (sharedEntry !== null && adoptSharedElectronDist(sharedEntry, electronDistDir)) {
return
}
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const persistentCacheRoot =
process.env.ORCA_ELECTRON_PACKAGE_CACHE_ROOT || process.env.ELECTRON_CACHE || null
@@ -158,11 +180,73 @@ async function installElectronPackageBinary() {
}
moveExtractedElectronDist(extractDir, electronDistDir)
if (sharedEntry !== null) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
/**
* Point this worktree's dist at the copy its siblings already share, so the ~295MB tree costs one
* allocation per repository instead of one per worktree.
*
* Staged inside node_modules/electron on purpose: clonefile only shares blocks within a volume, and
* staging elsewhere would silently downgrade the publish rename to a cross-device byte copy.
*/
function adoptSharedElectronDist(sharedEntry, electronDistDir) {
const stageRoot = mkdtempSync(resolve(electronPackageDir, '.dist-clone-'))
try {
const stagePath = join(stageRoot, 'dist')
if (
!shareElectronDistFromCache(sharedEntry, stagePath, {
version: electronVersion,
platformPath
})
) {
return false
}
moveExtractedElectronDist(stagePath, electronDistDir)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
console.log(
`[electron-package] Shared Electron ${electronVersion} from ${sharedEntry.entryPath}`
)
return true
} catch (error) {
// The download path below is always a correct fallback, so sharing never fails an install.
console.warn(`[electron-package] Shared Electron dist unavailable: ${formatShareError(error)}`)
return false
} finally {
rmSync(stageRoot, { recursive: true, force: true })
}
}
/** An already-installed dist joins the cache: clone from it if it exists, seed it otherwise. */
function shareExistingElectronDist(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (!adoptSharedElectronDist(sharedEntry, electronDistDir)) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
}
function publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir) {
const published = publishSharedElectronDist(electronDistDir, sharedEntry, {
version: electronVersion,
platformPath
})
if (published) {
console.log(
`[electron-package] Published Electron ${electronVersion} to ${sharedEntry.entryPath}`
)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
}
}
function formatShareError(error) {
return error instanceof Error ? error.message : String(error)
}
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
const retryDelays = getDownloadRetryDelays()
@@ -438,19 +522,3 @@ function getElectronTargetPlatform() {
function getElectronTargetArch() {
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
}
function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -1,22 +1,32 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
import {
addSiblingWorktree,
initGitRepo,
mkTempProject,
readExtractorCallCount,
readSharedDistMarker,
runInstallScript,
sharedCacheRoot,
sharedEntryName,
sharedEntryNameFor,
writeFakeElectronDist,
writeFakeElectronGet,
writeFakeElectronPackage,
writeFakeExtractor,
writeNonDarwinPlatformPreload,
writeTypeDefPublishFailurePreload
} from './install-electron-package-binary-test-fixtures.mjs'
describe('install-electron-package-binary', () => {
it('installs Electron from an isolated cache and repairs path.txt', () => {
@@ -250,7 +260,9 @@ describe('install-electron-package-binary', () => {
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(cacheRoot, 'preserved.marker'))).toBe(true)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')).toHaveLength(2)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -401,6 +413,199 @@ describe('install-electron-package-binary', () => {
}
})
// The shared cache is macOS-only: it exists to avoid a second copy via APFS clonefile.
it('publishes a shared Electron dist entry after a fresh download', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
expect(result.status, result.stderr).toBe(0)
expect(lstatSync(entryPath).isDirectory()).toBe(true)
expect(lstatSync(entryPath).isSymbolicLink()).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(entryPath, 'electron'))).toBe(true)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(result.stdout).toMatch(/Published Electron 41\.5\.0 to .*41\.5\.0-linux-x64$/m)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('shares the Electron dist into a sibling worktree without downloading', () => {
const projectDir = mkTempProject()
const siblingDir = `${projectDir}-sibling`
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
addSiblingWorktree(projectDir, siblingDir)
writeFakeElectronPackage(siblingDir)
writeFakeElectronGet(siblingDir)
writeFakeExtractor(siblingDir, { createExecutable: true })
const result = runInstallScript(siblingDir, { CI: '' })
const siblingDistDir = join(siblingDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(siblingDir)).toBe(0)
expect(existsSync(join(siblingDir, 'electron-get.log'))).toBe(false)
expect(lstatSync(siblingDistDir).isDirectory()).toBe(true)
expect(lstatSync(siblingDistDir).isSymbolicLink()).toBe(false)
expect(readFileSync(join(siblingDistDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(siblingDistDir, 'electron'))).toBe(true)
expect(readFileSync(join(siblingDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
expect(readSharedDistMarker(siblingDir)).toBe(sharedEntryName)
expect(result.stdout).toContain('Shared Electron 41.5.0 from')
} finally {
rmSync(siblingDir, { recursive: true, force: true })
rmSync(projectDir, { recursive: true, force: true })
}
})
it('publishes an already installed Electron dist that predates the shared cache', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
writeFakeElectronDist(projectDir, {
executableContents: 'existing executable',
pathContents: 'electron'
})
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(projectDir)).toBe(0)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'electron'), 'utf8')).toBe('existing executable')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readFileSync(join(distDir, 'electron'), 'utf8')).toBe('existing executable')
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('replaces a corrupt shared Electron dist entry instead of re-downloading forever', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
mkdirSync(entryPath, { recursive: true })
writeFileSync(join(entryPath, 'version'), 'v40.0.0')
writeFileSync(join(entryPath, 'electron'), 'stale executable')
const result = runInstallScript(projectDir, { CI: '' })
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Failed to install Electron package binary')
expect(readExtractorCallCount(projectDir)).toBe(1)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
// Why not just fall back: an entry left corrupt makes every sibling worktree download again.
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readdirSync(sharedCacheRoot(projectDir))).toEqual([sharedEntryName])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('hardlinks the shared Electron dist on a host without copy-on-write', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const preloadPath = writeNonDarwinPlatformPreload(projectDir)
const nonDarwinEnv = {
CI: '',
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath}`]
.filter(Boolean)
.join(' ')
}
const result = runInstallScript(projectDir, nonDarwinEnv)
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
// Why read-only: these are the same inodes, so an extract over dist would otherwise rewrite
// the cache and every sibling worktree at once.
expect(statSync(join(entryPath, 'electron')).mode & 0o222).toBe(0)
expect(statSync(join(entryPath, 'electron')).ino).toBe(
statSync(join(distDir, 'electron')).ino
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('gives an Electron upgrade its own cache entry and leaves the old one for other branches', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('41.5.0'))
// Upgrade the pinned Electron, exactly as a branch bumping the dependency would.
writeFakeElectronPackage(projectDir, { version: '42.0.0' })
writeFakeExtractor(projectDir, { createExecutable: true, version: '42.0.0' })
const upgraded = runInstallScript(projectDir, { CI: '' })
const cacheRoot = sharedCacheRoot(projectDir)
expect(upgraded.status, upgraded.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'node_modules/electron/dist/version'), 'utf8')).toBe(
'v42.0.0'
)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('42.0.0'))
// Why the old entry stays: sibling worktrees on the previous branch still share it.
expect(readdirSync(cacheRoot).sort()).toEqual([
sharedEntryNameFor('41.5.0'),
sharedEntryNameFor('42.0.0')
])
expect(readFileSync(join(cacheRoot, sharedEntryNameFor('41.5.0'), 'version'), 'utf8')).toBe(
'v41.5.0'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not exit successfully when Electron download never settles', () => {
const projectDir = mkTempProject()
@@ -419,155 +624,3 @@ describe('install-electron-package-binary', () => {
}
})
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
return projectDir
}
function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(
join(electronDir, 'package.json'),
JSON.stringify({ name: 'electron', version: '41.5.0' })
)
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
function writeFakeExtractor(projectDir, { createExecutable }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
@@ -18,6 +18,24 @@ function readSkill(path = guidePath) {
}
describe('orca CLI skill guidance', () => {
it('keeps external browser routing at the OS/page boundary', () => {
const skill = readSkill(guidePath)
const description = skill.replace(/\s+/gu, ' ')
expect(description).toContain(
'Use Computer Use for external browser windows, webviews, or desktop UI only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots.'
)
expect(description).toContain(
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
)
expect(skill).toContain(
'For external Chrome/Safari/webviews or Orca app chrome/settings, use the Computer Use skill/tool only when the task requires OS/window-level control'
)
expect(skill).toContain(
"Use `orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages"
)
})
it('keeps independent worktree lineage separate from Git base selection', () => {
const skill = readSkill()
@@ -25,6 +25,17 @@ function getSection(markdown, heading) {
}
describe('orchestration skill guidance', () => {
it('keeps external browser routing at the OS/page boundary', () => {
const description = readFileSync(guidePath, 'utf8').replace(/\s+/gu, ' ')
expect(description).toContain(
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
)
expect(description).toContain(
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
)
})
it('requires Orca runtime state before claiming a worker was orchestrated', () => {
const skill = readSkill()
const toolBoundary = getSection(skill, 'Tool Boundary')
+3 -1
View File
@@ -316,7 +316,9 @@ describe('PR E2E gate contract', () => {
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-target-sync-test-harness.ts'])
selectPrE2eSpecs([
'src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts'
])
).toEqual([])
})
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -44,7 +45,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -80,7 +81,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
).toBe('// napi.h\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -104,7 +105,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64')
expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64')
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -132,7 +133,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['windows-native-registry'])
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -162,7 +163,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -193,7 +194,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
expect(rebuildCall.ignoreModules).toEqual(['cpu-features'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -221,7 +222,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -251,7 +252,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -3,6 +3,7 @@ import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from '
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
const sourceInstallScriptPath = fileURLToPath(
@@ -19,10 +20,7 @@ export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs'))
copyFileSync(
sourceInstallScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts'))
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
+10 -9
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
import {
mkTempProject,
@@ -36,7 +37,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'download attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -60,7 +61,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -81,7 +82,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -117,7 +118,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'stale-path'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -141,7 +142,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'platform=linux arch=arm64\ndownload attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -162,7 +163,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -188,7 +189,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'electron.exe'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -209,7 +210,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'platform=linux arch=x64'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -230,7 +231,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
})
@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Removes idle `out/electron-dev` bundles across every worktree of a repository.
//
// The dev runner already prunes these, but only within the worktree it is starting and only when
// that worktree holds more than one bundle -- and a worktree almost always holds exactly one. So
// nothing ever reclaims a bundle belonging to a worktree you are not currently running, and one
// ~275MB copy per branch accumulates indefinitely.
//
// Bundles are pure build output: `pnpm dev` rebuilds one on demand, and since the Electron dist is
// now shared, rebuilding is cheap.
import { execFileSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
const apply = process.argv.includes('--apply')
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
function listWorktrees(root) {
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
encoding: 'utf8'
})
return raw
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
}
function measure(targetPath) {
let total = 0
let entries
try {
entries = readdirSync(targetPath, { withFileTypes: true })
} catch {
return 0
}
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name)
if (entry.isDirectory()) {
total += measure(entryPath)
} else if (!entry.isSymbolicLink()) {
total += statSync(entryPath, { throwIfNoEntry: false })?.size ?? 0
}
}
return total
}
export function collectDevBundles(worktree) {
const root = path.join(worktree, 'out', 'electron-dev')
try {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME)),
mtimeMs: statSync(dir, { throwIfNoEntry: false })?.mtimeMs ?? 0
}
})
} catch {
return []
}
}
function main() {
// The patched dev bundle is only built on macOS; elsewhere the dev app runs from dist directly.
if (process.platform !== 'darwin') {
console.log('No dev Electron bundles on this platform; nothing to reclaim.')
return
}
const processTable = getDevBundleProcessTable()
if (processTable === null) {
// Same rule the dev runner uses: no process table means we cannot prove a bundle is idle.
console.error('Could not read the process table; refusing to guess which bundles are idle.')
process.exitCode = 1
return
}
const bundles = listWorktrees(repoRoot).flatMap((worktree) => collectDevBundles(worktree))
// currentDir is null on purpose: unlike the dev runner, this sweep is not about to launch anything,
// so the only thing protecting a bundle is a live process or an in-flight build.
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: null,
processTable,
nowMs: Date.now()
})
let reclaimed = 0
let removed = 0
for (const dir of stale) {
const size = measure(dir)
if (!apply) {
console.log(`would remove ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`)
reclaimed += size
removed += 1
continue
}
try {
rmSync(dir, { recursive: true, force: true })
reclaimed += size
removed += 1
console.log(`removed ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`)
} catch (error) {
console.warn(`skip ${dir} (${error instanceof Error ? error.message : String(error)})`)
}
}
const inUse = bundles.length - stale.length
console.log(
`\n${apply ? 'Removed' : 'Would remove'} ${removed} bundle(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${inUse > 0 ? `; left ${inUse} in use or still building` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
}
// Guarded so importing this module for tests does not sweep the whole repository.
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main()
}
@@ -0,0 +1,97 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { collectDevBundles } from './reclaim-dev-electron-bundles.mjs'
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeWorktree(bundles: { name: string; marker: boolean }[]): string {
const worktree = mkdtempSync(path.join(tmpdir(), 'orca-dev-bundles-'))
roots.push(worktree)
for (const bundle of bundles) {
const dir = path.join(worktree, 'out', 'electron-dev', bundle.name)
mkdirSync(dir, { recursive: true })
if (bundle.marker) {
writeFileSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME), '{}')
}
}
return worktree
}
describe('collectDevBundles', () => {
it('reports each bundle and whether its build finished', () => {
const worktree = makeWorktree([
{ name: 'aaaa', marker: true },
{ name: 'bbbb', marker: false }
])
const bundles = collectDevBundles(worktree).sort((a, b) => a.dir.localeCompare(b.dir))
expect(bundles).toHaveLength(2)
expect(bundles[0].hasMarker).toBe(true)
expect(bundles[1].hasMarker).toBe(false)
expect(bundles[0].mtimeMs).toBeGreaterThan(0)
})
it('returns nothing for a worktree that has never run the dev app', () => {
const worktree = mkdtempSync(path.join(tmpdir(), 'orca-dev-bundles-'))
roots.push(worktree)
expect(collectDevBundles(worktree)).toEqual([])
})
})
describe('sweeping across worktrees', () => {
it('spares a bundle a live process is running from, and takes the idle ones', () => {
const worktree = makeWorktree([
{ name: 'live', marker: true },
{ name: 'idle', marker: true }
])
const bundles = collectDevBundles(worktree)
const live = bundles.find((bundle) => bundle.dir.endsWith('live'))!
// Why currentDir is null here: unlike the dev runner, the sweep is not about to launch
// anything, so only a live process or an in-flight build may protect a bundle.
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: null,
processTable: `/usr/bin/foo ${live.dir}/Orca.app/Contents/MacOS/Electron`,
nowMs: Date.now()
})
expect(stale).toEqual([bundles.find((bundle) => bundle.dir.endsWith('idle'))!.dir])
})
it('spares a build still in flight, which has no marker yet', () => {
const worktree = makeWorktree([{ name: 'building', marker: false }])
const stale = selectStaleDevBundleDirs({
bundles: collectDevBundles(worktree),
currentDir: null,
processTable: '',
nowMs: Date.now()
})
expect(stale).toEqual([])
})
})
describe('getDevBundleProcessTable', () => {
it('returns null rather than an empty table when ps fails', () => {
expect(
getDevBundleProcessTable(() => {
throw new Error('ps unavailable')
})
).toBeNull()
})
it('reads the real process table on this host', () => {
const table = getDevBundleProcessTable()
expect(typeof table === 'string' || table === null).toBe(true)
})
})
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Converts worktrees that already have their own Electron dist over to the shared cache.
// A normal install only shares when Electron is (re)installed, and an existing healthy worktree
// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade.
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
existsSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import path from 'node:path'
import {
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry,
shareElectronDistFromCache
} from './shared-electron-dist-cache.mjs'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
const apply = process.argv.includes('--apply')
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
function listWorktrees(root) {
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
encoding: 'utf8'
})
return raw
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
}
/** Apparent size, walked in Node: `du` does not exist on Windows, where it silently reported 0. */
function measure(targetPath) {
let total = 0
let entries
try {
entries = readdirSync(targetPath, { withFileTypes: true })
} catch {
return 0
}
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name)
if (entry.isDirectory()) {
total += measure(entryPath)
} else if (!entry.isSymbolicLink()) {
total += statSync(entryPath, { throwIfNoEntry: false })?.size ?? 0
}
}
return total
}
/** Swap in a shared copy behind a rename, so an interrupted run never leaves a partial dist. */
function adoptInto(distPath, entry, identity) {
const stagePath = `${distPath}.reclaim-${process.pid}-${randomUUID()}`
if (!shareElectronDistFromCache(entry, stagePath, identity)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
const previousPath = `${distPath}.previous-${process.pid}-${randomUUID()}`
renameSync(distPath, previousPath)
try {
renameSync(stagePath, distPath)
} catch (error) {
renameSync(previousPath, distPath)
rmSync(stagePath, { recursive: true, force: true })
throw error
}
rmSync(previousPath, { recursive: true, force: true })
return true
}
function main() {
let reclaimed = 0
let converted = 0
let skipped = 0
for (const worktree of listWorktrees(repoRoot)) {
const electronPackageDir = path.join(worktree, 'node_modules', 'electron')
const distPath = path.join(electronPackageDir, 'dist')
if (!existsSync(path.join(electronPackageDir, 'package.json')) || !existsSync(distPath)) {
continue
}
if (statSync(distPath, { throwIfNoEntry: false })?.isDirectory() !== true) {
continue
}
let version
try {
version = JSON.parse(
readFileSync(path.join(electronPackageDir, 'package.json'), 'utf8')
).version
} catch {
continue
}
const targetPlatform = process.platform
const targetArch = process.arch
let platformPath
try {
platformPath = getElectronPlatformPath(targetPlatform)
} catch {
continue
}
if (!isUsableElectronDist(distPath, version, platformPath)) {
console.log(`skip ${worktree} (dist is not a complete Electron ${version})`)
skipped += 1
continue
}
const entry = resolveSharedElectronDistEntry({
repoRoot: worktree,
electronPackageDir,
version,
targetPlatform,
targetArch
})
if (entry === null) {
continue
}
if (hasAdoptedSharedElectronDist(entry)) {
continue
}
const size = measure(distPath)
if (!apply) {
console.log(`would share ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB (${version})`)
reclaimed += size
converted += 1
continue
}
try {
if (!existsSync(entry.entryPath)) {
if (publishSharedElectronDist(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
console.log(`seeded ${worktree} -> ${entry.entryPath}`)
converted += 1
}
continue
}
if (adoptInto(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
reclaimed += size
converted += 1
console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`)
}
} catch (error) {
// A worktree that fails is left exactly as it was; it still has its own working dist.
console.warn(`skip ${worktree} (${error instanceof Error ? error.message : String(error)})`)
skipped += 1
}
}
console.log(
`\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${skipped > 0 ? `; skipped ${skipped}` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
}
// Guarded so importing this module for tests does not sweep the whole repository.
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main()
}
@@ -41,14 +41,24 @@ describe('release-cut source map publication', () => {
expect(publish.with.command).toContain('runner.temp')
})
it('fails the release when no source maps were emitted', () => {
// Why: a silent regression of build.sourcemap would ship an undecodable
// release rather than an obviously broken one.
it('fails only when a map-enabled cut emits no source maps', () => {
// Why: legacy tags predate source-map publication, but a newer tag that
// enables hidden maps must still fail loudly if the build regresses.
const bundle = buildSteps[stepIndex('Bundle main-process source maps')]
expect(bundle.id).toBe('bundle-main-sourcemaps')
expect(bundle.run).toContain('grep -Eq')
expect(bundle.run).toContain('sourcemap:[[:space:]]*')
expect(bundle.run).toContain('has_maps=false')
expect(bundle.run).toContain('has_maps=true')
expect(bundle.run).toContain('::error::')
expect(bundle.run).toContain('exit 1')
})
it('skips publication for legacy cut refs without hidden source maps', () => {
const publish = buildSteps[stepIndex('Publish main-process source maps')]
expect(publish.if).toContain("steps.bundle-main-sourcemaps.outputs.has_maps == 'true'")
})
it('bundles maps after the build and before packaging strips them', () => {
const bundle = stepIndex('Bundle main-process source maps')
expect(bundle).toBeGreaterThan(stepIndex('Build app'))
@@ -31,6 +31,7 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' },
+13 -24
View File
@@ -1,7 +1,6 @@
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
@@ -16,8 +15,15 @@ import {
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
isDevBundleInUse,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { copyPrivateTree } from './space-sharing-copy.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
@@ -116,23 +122,6 @@ function sanitizeMacAppBundleName(value) {
)
}
function getDevBundleProcessTable() {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs
// for why it is deliberately not parsed into paths.
try {
return execFileSync('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
function pruneStaleDevBundles(distDir) {
const root = path.dirname(distDir)
let bundles
@@ -143,7 +132,7 @@ function pruneStaleDevBundles(distDir) {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')),
hasMarker: existsSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME)),
mtimeMs: getMtimeMs(dir)
}
})
@@ -203,7 +192,7 @@ function prepareMacDevElectronApp() {
// and it sits outside the code signature, so varying it does not disturb the cdhash.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const markerPath = path.join(distDir, DEV_BUNDLE_MARKER_FILENAME)
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
@@ -298,9 +287,9 @@ function prepareMacDevElectronApp() {
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
// Why: Electron.framework uses relative symlinks for its bundle resources;
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
// Why clone-first: this ~280MB copy is made per branch title x Electron version, and only the
// plist/helper/codesign bytes patched below ever diverge from the source.
copyPrivateTree(sourceAppPath, appPath)
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
@@ -0,0 +1,26 @@
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
/**
* Copy a script and every co-located module it imports into a fixture's `config/scripts`.
*
* Walked rather than listed: a module the script needs but the fixture never copied fails every
* test in the suite with a module-resolution error that looks nothing like the defect it hides.
*/
export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) {
mkdirSync(destinationScriptsDir, { recursive: true })
for (const modulePath of collectScriptModules(sourceScriptPath)) {
copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath)))
}
}
function collectScriptModules(scriptPath, seen = new Set()) {
if (seen.has(scriptPath)) {
return seen
}
seen.add(scriptPath)
for (const [, specifier] of readFileSync(scriptPath, 'utf8').matchAll(/from '(\.\/[^']+)'/g)) {
collectScriptModules(join(dirname(scriptPath), specifier), seen)
}
return seen
}
@@ -0,0 +1,189 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
import path from 'node:path'
import { makeTreeReadOnly, shareTree } from './space-sharing-copy.mjs'
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
// Sibling of path.txt, never inside dist: an install replaces dist wholesale.
const MARKER_FILENAME = '.orca-shared-dist'
/**
* Where sibling worktrees of one repository keep their shared extracted Electron.
*
* Null means "install normally" -- every caller treats a missing entry as "do what you did before".
*/
export function resolveSharedElectronDistEntry(options) {
const { repoRoot, version, targetPlatform, targetArch } = options
const env = options.env ?? process.env
// Packaging jobs get a fresh checkout per run, so a cache only adds a failure mode.
if (env.CI === '1' || env.CI === 'true') {
return null
}
if (![version, targetPlatform, targetArch].every((part) => IDENTITY_PATTERN.test(part ?? ''))) {
return null
}
let gitCommonDir
try {
gitCommonDir = resolveGitCommonDir(repoRoot, options.execFile ?? execFileSync)
} catch {
return null // Folder workspace, or no Git on PATH.
}
const cacheRoot = path.join(gitCommonDir, 'orca-cache', 'electron')
return {
cacheRoot,
entryPath: path.join(cacheRoot, `${version}-${targetPlatform}-${targetArch}`),
markerPath: path.join(options.electronPackageDir, MARKER_FILENAME)
}
}
export function resolveGitCommonDir(repoRoot, execFile = execFileSync) {
const rawPath = execFile('git', ['-C', repoRoot, 'rev-parse', '--git-common-dir'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
if (!rawPath) {
throw new Error('Git returned an empty common directory')
}
return path.resolve(repoRoot, rawPath)
}
/** True once this worktree's dist is already a clone of the current cache entry. */
export function hasAdoptedSharedElectronDist(entry) {
try {
return readFileSync(entry.markerPath, 'utf8') === path.basename(entry.entryPath)
} catch {
return false
}
}
export function recordAdoptedSharedElectronDist(entry, write) {
try {
write(entry.markerPath, path.basename(entry.entryPath))
} catch {
// The marker is only an optimization: a missing one costs one extra clone.
}
}
/**
* Share a validated cache entry into `stagePath`. Deliberately never falls back to a byte copy --
* with no storage to share the caller is better off with its normal install, which this must not
* slow down.
*/
export function shareElectronDistFromCache(entry, stagePath, options) {
const { version, platformPath } = options
if (!isUsableElectronDist(entry.entryPath, version, platformPath)) {
return false
}
try {
;(options.share ?? shareTree)(entry.entryPath, stagePath)
} catch {
return false
}
return isUsableElectronDist(stagePath, version, platformPath)
}
/**
* Publish this worktree's dist as the shared entry, best effort.
*
* No lock: staging names are unique and `rename` onto a populated directory fails with ENOTEMPTY,
* so a concurrent publisher either wins the rename or cleans up its own staging tree. Neither can
* observe a half-written entry, and a usable entry already in place is never overwritten.
*/
export function publishSharedElectronDist(distPath, entry, options = {}) {
const { version, platformPath } = options
const uuid = options.uuid ?? randomUUID
const canValidate = Boolean(version) && Boolean(platformPath)
// Without an identity to check against, "unusable" is unknowable -- never discard on a guess.
if (existsSync(entry.entryPath) && (!canValidate || isUsable(entry, version, platformPath))) {
return false
}
const stagePath = `${entry.entryPath}.staging-${process.pid}-${uuid()}`
try {
mkdirSync(entry.cacheRoot, { recursive: true })
;(options.share ?? shareTree)(distPath, stagePath)
// Before publishing, not after: an entry is visible the instant the rename lands, and under
// hardlink sharing this is the only thing standing between a stray write and every worktree.
;(options.protect ?? makeTreeReadOnly)(stagePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false
}
return swapInElectronDistEntry(entry, stagePath, {
canValidate,
version,
platformPath,
uuid,
rename: options.rename ?? renameSync
})
}
/**
* Replace the entry with a staged tree, re-checking first.
*
* Sharing the tree above takes seconds, and a sibling worktree can publish a perfectly good entry
* in that time. Re-validating here, immediately before the destructive rename, keeps us from
* discarding that entry -- and restoring the quarantine on a failed swap keeps a losing publisher
* from leaving the cache empty.
*/
function swapInElectronDistEntry(entry, stagePath, options) {
const { canValidate, version, platformPath, uuid, rename } = options
let quarantinePath = null
if (existsSync(entry.entryPath)) {
// Same rule as before staging: an entry we cannot judge, or one that is good, is never
// displaced. Both mean another worktree got there first, so keep theirs.
if (!canValidate || isUsable(entry, version, platformPath)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
quarantinePath = `${entry.entryPath}.unusable-${process.pid}-${uuid()}`
try {
renameSync(entry.entryPath, quarantinePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false // Another worktree is already replacing it.
}
}
try {
rename(stagePath, entry.entryPath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
if (quarantinePath !== null) {
// Put it back rather than leave no entry at all; a bad entry still beats an empty cache,
// because the next publisher re-validates and replaces it.
try {
renameSync(quarantinePath, entry.entryPath)
return false
} catch {
rmSync(quarantinePath, { recursive: true, force: true })
}
}
return false
}
if (quarantinePath !== null) {
rmSync(quarantinePath, { recursive: true, force: true })
}
return true
}
function isUsable(entry, version, platformPath) {
return isUsableElectronDist(entry.entryPath, version, platformPath)
}
export function isUsableElectronDist(distPath, version, platformPath) {
try {
if (!lstatSync(distPath).isDirectory()) {
return false
}
const installedVersion = readFileSync(path.join(distPath, 'version'), 'utf8')
.trim()
.replace(/^v/, '')
return installedVersion === version && existsSync(path.join(distPath, platformPath))
} catch {
return false
}
}
@@ -0,0 +1,358 @@
import type { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
import { makeTreeReadOnly } from './space-sharing-copy.mjs'
const VERSION = '43.4.1'
const PLATFORM_PATH = path.join('Electron.app', 'Contents', 'MacOS', 'Electron')
const identity = { version: VERSION, platformPath: PLATFORM_PATH }
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'orca-shared-electron-'))
roots.push(root)
return root
}
function writeDist(distPath: string, version = VERSION): string {
mkdirSync(path.join(distPath, path.dirname(PLATFORM_PATH)), { recursive: true })
writeFileSync(path.join(distPath, 'version'), `v${version}\n`)
writeFileSync(path.join(distPath, PLATFORM_PATH), 'electron')
return distPath
}
function makeEntry(root: string, entryName = `${VERSION}-darwin-arm64`) {
const cacheRoot = path.join(root, 'cache')
return {
cacheRoot,
entryPath: path.join(cacheRoot, entryName),
markerPath: path.join(root, '.orca-shared-dist')
}
}
const baseOptions = {
repoRoot: '/repo',
electronPackageDir: '/repo/node_modules/electron',
version: VERSION,
targetPlatform: 'darwin',
targetArch: 'arm64',
hostPlatform: 'darwin' as const,
env: {} as NodeJS.ProcessEnv,
execFile: (() => '/repo/.git\n') as unknown as typeof execFileSync
}
describe('resolveSharedElectronDistEntry', () => {
it('keys the entry by version, platform, and arch under the git common dir', () => {
const entry = resolveSharedElectronDistEntry(baseOptions)
expect(entry?.cacheRoot).toBe(path.join('/repo/.git', 'orca-cache', 'electron'))
expect(entry?.entryPath).toBe(
path.join('/repo/.git', 'orca-cache', 'electron', '43.4.1-darwin-arm64')
)
expect(entry?.markerPath).toBe(path.join('/repo/node_modules/electron', '.orca-shared-dist'))
})
it('offers an entry on every platform a worktree is developed on', () => {
for (const hostPlatform of ['darwin', 'linux', 'win32']) {
expect(resolveSharedElectronDistEntry({ ...baseOptions, hostPlatform })).not.toBeNull()
}
})
it('declines on CI, where every job gets a fresh checkout', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: '1' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'true' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'false' } })).not.toBeNull()
})
it('declines outside a Git worktree so folder workspaces install normally', () => {
const execFile = (() => {
throw new Error('not a git repository')
}) as unknown as typeof execFileSync
expect(resolveSharedElectronDistEntry({ ...baseOptions, execFile })).toBeNull()
})
it('declines an identity that would not be a single safe path segment', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetArch: '../escape' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetPlatform: 'dar/win' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, version: '' })).toBeNull()
})
})
describe('isUsableElectronDist', () => {
it('accepts a complete dist and tolerates the leading v in the version file', () => {
const root = makeRoot()
expect(isUsableElectronDist(writeDist(path.join(root, 'dist')), VERSION, PLATFORM_PATH)).toBe(
true
)
})
it('rejects a version mismatch, a missing executable, and a missing directory', () => {
const root = makeRoot()
expect(
isUsableElectronDist(writeDist(path.join(root, 'a'), '40.0.0'), VERSION, PLATFORM_PATH)
).toBe(false)
const partial = path.join(root, 'b')
mkdirSync(partial, { recursive: true })
writeFileSync(path.join(partial, 'version'), `v${VERSION}`)
expect(isUsableElectronDist(partial, VERSION, PLATFORM_PATH)).toBe(false)
expect(isUsableElectronDist(path.join(root, 'missing'), VERSION, PLATFORM_PATH)).toBe(false)
})
it('rejects a symlink so a redirected entry is never treated as cache content', () => {
const root = makeRoot()
writeDist(path.join(root, 'real'))
symlinkSync(path.join(root, 'real'), path.join(root, 'link'), 'dir')
expect(isUsableElectronDist(path.join(root, 'link'), VERSION, PLATFORM_PATH)).toBe(false)
})
})
describe('publishSharedElectronDist', () => {
it('publishes through a staging directory and an atomic rename', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const share = vi.fn((source: string, destination: string) => {
expect(path.basename(destination)).toMatch(/^43\.4\.1-darwin-arm64\.staging-/)
writeDist(destination)
expect(source).toBe(dist)
})
expect(publishSharedElectronDist(dist, entry, { share, ...identity })).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('publishes the entry read-only, before it is reachable under its final name', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const protectedPaths: string[] = []
const share = (source: string, destination: string) => {
writeDist(destination)
expect(source).toBe(dist)
}
const protect = (target: string) => {
// Why order matters: a reader can clone the entry the instant the rename lands.
expect(existsSync(entry.entryPath)).toBe(false)
protectedPaths.push(target)
makeTreeReadOnly(target)
}
expect(publishSharedElectronDist(dist, entry, { share, protect, ...identity })).toBe(true)
expect(protectedPaths).toHaveLength(1)
expect(statSync(path.join(entry.entryPath, 'version')).mode & 0o222).toBe(0)
// Entry directories stay removable, which is what the install transaction actually needs.
expect(() => rmSync(entry.entryPath, { recursive: true })).not.toThrow()
})
it('never overwrites an entry another worktree already published', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'first-writer')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
})
it('loses a publish race without clobbering the winner or leaking staging', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
// The winner lands between our existence check and our rename.
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'winner')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('keeps a good entry a sibling published while this one was still sharing', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0') // Unusable, so this worktree intends to replace it.
const share = (_source: string, destination: string) => {
writeDist(destination)
// A sibling replaces the bad entry with a good one while this share is still running.
rmSync(entry.entryPath, { recursive: true, force: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'sibling')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('restores the quarantined entry rather than leaving the cache empty', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
writeFileSync(path.join(entry.entryPath, 'marker'), 'stale')
const share = (_source: string, destination: string) => writeDist(destination)
// The swap itself fails; a bad entry still beats no entry, since the next publisher replaces it.
const failingRename = () => {
throw new Error('rename failed')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, {
share,
rename: failingRename,
...identity
})
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('replaces an entry that fails validation instead of stranding every worktree', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = (_source: string, destination: string) => writeDist(destination)
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('never discards an entry it was given no identity to check', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'version'))).toBe(true)
})
it('leaves no entry and no staging tree when sharing fails', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
throw new Error('no shareable storage')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(entry.entryPath)).toBe(false)
expect(readdirSync(entry.cacheRoot)).toEqual([])
})
})
describe('shareElectronDistFromCache', () => {
it('shares a validated entry with real filesystem semantics', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(true)
expect(isUsableElectronDist(stagePath, VERSION, PLATFORM_PATH)).toBe(true)
})
it('refuses an entry that fails validation instead of installing it', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
it('reports failure rather than falling back to a full copy', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
// Injected rather than provoked: what counts as an unshareable destination differs per
// mechanism, and a byte-copy fallback here would defeat the point of the cache.
const stagePath = path.join(root, 'stage')
const share = () => {
throw new Error('no shareable storage')
}
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH,
share
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
})
describe('shared dist marker', () => {
it('reports adoption only for the entry the marker names', () => {
const root = makeRoot()
const entry = makeEntry(root)
expect(hasAdoptedSharedElectronDist(entry)).toBe(false)
recordAdoptedSharedElectronDist(entry, writeFileSync)
expect(hasAdoptedSharedElectronDist(entry)).toBe(true)
expect(
hasAdoptedSharedElectronDist({
...entry,
entryPath: path.join(entry.cacheRoot, '44.0.0-darwin-arm64')
})
).toBe(false)
})
it('swallows a marker write failure, which only costs one extra share', () => {
const entry = makeEntry(makeRoot())
expect(() =>
recordAdoptedSharedElectronDist(entry, () => {
throw new Error('read-only node_modules')
})
).not.toThrow()
})
})
@@ -10,6 +10,27 @@ function stepNamed(job, name) {
}
describe('skill-sharing release workflow', () => {
it('keeps artifact builds behind every blocking release gate', () => {
const preflight = workflow.jobs['release-preflight']
const build = workflow.jobs.build
const macBuild = workflow.jobs['build-mac']
expect(preflight.needs).toEqual([
'cut',
'terminal-rendering-golden',
'skill-sharing-release-gate',
'skill-sharing-linux-floor-release-gate'
])
expect(preflight.if).toContain('always()')
expect(preflight.if).toContain("needs.terminal-rendering-golden.result == 'success'")
expect(preflight.if).toContain("needs.skill-sharing-release-gate.result == 'success'")
expect(preflight.if).toContain(
"needs.skill-sharing-linux-floor-release-gate.result == 'success'"
)
expect(build.needs).toContain('release-preflight')
expect(macBuild.needs).toContain('release-preflight')
})
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
+147
View File
@@ -0,0 +1,147 @@
import { execFileSync } from 'node:child_process'
import {
chmodSync,
cpSync,
linkSync,
mkdirSync,
readdirSync,
readlinkSync,
rmSync,
statSync,
symlinkSync
} from 'node:fs'
import { join } from 'node:path'
// -c asks for clonefile(2). -P keeps Electron.framework's relative symlinks as symlinks; resolving
// them breaks Chromium's bundle lookup.
export const MACOS_CLONE_ARGS = Object.freeze(['-c', '-R', '-P'])
// -a implies -d (no symlink following) and preserves mode. --reflink=always fails loudly on a
// filesystem without reflinks rather than silently writing a second full copy.
export const LINUX_REFLINK_ARGS = Object.freeze(['--reflink=always', '-a'])
/**
* Copy a directory tree so the destination costs no new storage.
*
* Three mechanisms, strongest isolation first. Clone and reflink are copy-on-write, so the
* destination is genuinely private. Hardlinks are not: the two trees share inodes, and a write
* through either mutates both. That is only sound for a tree nothing writes to, which is why
* `makeTreeReadOnly` exists and why the caller must apply it.
*
* Throws when no mechanism is available, so a caller can fall back to installing normally rather
* than silently paying for a second full copy.
*/
export function shareTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const errors = []
for (const mechanism of getShareMechanisms(platform)) {
try {
;(options[mechanism] ?? shareMechanisms[mechanism])(sourcePath, destinationPath)
return mechanism
} catch (error) {
errors.push(error)
// A mechanism can fail part-way through a tree; the next one needs a clean destination.
rmSync(destinationPath, { recursive: true, force: true })
}
}
throw new AggregateError(errors, `Could not share storage for ${destinationPath}`)
}
function getShareMechanisms(platform) {
switch (platform) {
case 'darwin':
// APFS only. HFS+ has no clonefile, and hardlinking a 585-entry bundle buys little.
return ['clone']
case 'linux':
// reflink covers btrfs/XFS/bcachefs/ZFS; ext4 has none, which is most developers.
return ['reflink', 'hardlink']
case 'win32':
// Block cloning is ReFS-only, so NTFS gets hardlinks or nothing.
return ['hardlink']
default:
return []
}
}
const shareMechanisms = {
clone: (sourcePath, destinationPath) =>
execFileSync('/bin/cp', [...MACOS_CLONE_ARGS, sourcePath, destinationPath], {
stdio: 'ignore'
}),
reflink: (sourcePath, destinationPath) =>
execFileSync('cp', [...LINUX_REFLINK_ARGS, sourcePath, destinationPath], { stdio: 'ignore' }),
hardlink: hardlinkTree
}
export function hardlinkTree(sourcePath, destinationPath) {
mkdirSync(destinationPath, { recursive: true })
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
const from = join(sourcePath, entry.name)
const to = join(destinationPath, entry.name)
if (entry.isDirectory()) {
hardlinkTree(from, to)
} else if (entry.isSymbolicLink()) {
symlinkSync(readlinkSync(from), to)
} else {
linkSync(from, to)
}
}
}
/**
* Drop write permission across a tree.
*
* This is what makes hardlink sharing safe: Electron's own install.js extracts over an existing
* dist with O_TRUNC, which through a hardlink would rewrite every sibling worktree and the cache at
* once. Read-only turns that into EPERM. Directories stay writable because unlink needs a writable
* parent, not a writable file, so the install transaction's renames still work.
*/
export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeReadOnly(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
// Clear the write bits and nothing else. A flat 0o555 would strip setuid from
// chrome-sandbox, and under hardlink sharing it would strip it in every worktree at once.
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o555 : mode & ~0o222)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const privateMechanisms = new Set(['clone', 'reflink'])
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
const mechanism = shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
})
return { mechanism, copyError: null }
} catch (copyError) {
copy(sourcePath, destinationPath)
return { mechanism: null, copyError }
}
}
copy(sourcePath, destinationPath)
return { mechanism: null, copyError: null }
}
function copyTreeVerbatim(sourcePath, destinationPath) {
cpSync(sourcePath, destinationPath, {
recursive: true,
dereference: false,
verbatimSymlinks: true
})
}
+221
View File
@@ -0,0 +1,221 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
LINUX_REFLINK_ARGS,
MACOS_CLONE_ARGS,
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
shareTree
} from './space-sharing-copy.mjs'
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeTree(): { root: string; source: string } {
const root = mkdtempSync(path.join(tmpdir(), 'orca-share-'))
roots.push(root)
const source = path.join(root, 'source')
mkdirSync(path.join(source, 'nested'), { recursive: true })
writeFileSync(path.join(source, 'nested', 'file'), 'contents')
symlinkSync(path.join('nested', 'file'), path.join(source, 'relative-link'))
return { root, source }
}
describe('shareTree', () => {
// Mechanism selection is asserted with stubs, because the real mechanisms only exist on the host
// that owns them: /bin/cp -c is macOS-only and `cp --reflink` is GNU-only.
it('prefers the strongest isolation each platform offers', () => {
const stub = () =>
vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
const stubs = { clone: stub(), reflink: stub(), hardlink: stub() }
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'a'), { platform: 'darwin', ...stubs })).toBe('clone')
expect(shareTree(source, path.join(root, 'b'), { platform: 'linux', ...stubs })).toBe('reflink')
expect(shareTree(source, path.join(root, 'c'), { platform: 'win32', ...stubs })).toBe(
'hardlink'
)
})
it('keeps relative symlinks unresolved on whatever this host supports', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
expect(shareTree(source, destination)).toBeTruthy()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('falls from reflink to hardlink on Linux, where ext4 has no reflinks', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = vi.fn(() => {
throw new Error('failed to clone: Invalid cross-device link')
})
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(reflink).toHaveBeenCalledOnce()
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
})
it('hardlinks on Windows, the only mechanism NTFS offers', () => {
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'shared'), { platform: 'win32' })).toBe('hardlink')
})
it('clears a part-way tree before trying the next mechanism', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = (_source: string, target: string) => {
mkdirSync(target, { recursive: true })
writeFileSync(path.join(target, 'half-written'), 'partial')
throw new Error('reflink failed midway')
}
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(existsSync(path.join(destination, 'half-written'))).toBe(false)
})
it('throws rather than silently paying for a second full copy', () => {
const { root, source } = makeTree()
expect(() => shareTree(source, path.join(root, 'shared'), { platform: 'freebsd' })).toThrow(
/Could not share storage/
)
})
it('fails loudly instead of degrading, on both copy-out mechanisms', () => {
expect(MACOS_CLONE_ARGS).toContain('-P')
expect(LINUX_REFLINK_ARGS).toContain('--reflink=always')
})
})
describe('hardlinkTree', () => {
it('shares inodes for files but recreates symlinks as their own entries', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('propagates a write through the shared inode, which is why callers must protect it', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('mutated')
})
})
describe('makeTreeReadOnly', () => {
it('drops write permission on files while leaving directories traversable and unlinkable', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
expect(statSync(path.join(source, 'nested', 'file')).mode & 0o222).toBe(0)
// Asserted as behavior, not mode bits: Windows maps chmod onto the read-only attribute alone,
// so a directory there never reports 0o755. What has to hold everywhere is that the install
// transaction can still rename dist aside and remove it.
expect(() => rmSync(path.join(source, 'nested'), { recursive: true })).not.toThrow()
})
it('turns an extract-over-dist write into an error instead of silent shared corruption', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
makeTreeReadOnly(destination)
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')).toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
})
it.runIf(process.platform !== 'win32')('preserves setuid, which chrome-sandbox needs', () => {
const { source } = makeTree()
const sandbox = path.join(source, 'chrome-sandbox')
writeFileSync(sandbox, 'binary')
chmodSync(sandbox, 0o4755)
makeTreeReadOnly(source)
expect(statSync(sandbox).mode & 0o4000).toBe(0o4000)
expect(statSync(sandbox).mode & 0o222).toBe(0)
})
it.runIf(process.platform !== 'win32')(
'keeps the executable bit, which Electron needs to launch',
() => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary', { mode: 0o755 })
makeTreeReadOnly(source)
// Verified on real ext4: 0o555. Windows has no execute bit -- the read-only attribute does
// not gate execution there, confirmed by running a read-only hardlinked .exe on NTFS.
expect(statSync(executable).mode & 0o111).toBe(0o111)
}
)
})
describe('copyPrivateTree', () => {
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
const result = copyPrivateTree(source, destination, { platform: 'linux', hardlink })
expect(hardlink).not.toHaveBeenCalled()
expect(statSync(path.join(destination, 'nested', 'file')).ino).not.toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(result.mechanism === 'reflink' || result.mechanism === null).toBe(true)
})
it('copies bytes on a platform with no private mechanism at all', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
expect(copyPrivateTree(source, destination, { platform: 'win32', hardlink })).toEqual({
mechanism: null,
copyError: null
})
expect(hardlink).not.toHaveBeenCalled()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('reports the private mechanism it used', () => {
const { root, source } = makeTree()
const clone = vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
expect(
copyPrivateTree(source, path.join(root, 'private'), { platform: 'darwin', clone })
).toEqual({
mechanism: 'clone',
copyError: null
})
})
it('falls back to a byte copy when the private mechanism fails', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const clone = () => {
throw new Error('clonefile unsupported')
}
const result = copyPrivateTree(source, destination, { platform: 'darwin', clone })
expect(result.mechanism).toBeNull()
expect(result.copyError).toBeInstanceOf(Error)
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
})
})
@@ -38,7 +38,7 @@ describe('Windows signing workflow contract', () => {
const installStep = steps[installStepIndexes[0]]
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.if).toBe("matrix.platform == 'win' && github.run_attempt == 1")
expect(installStep.uses).toBe('./.github/actions/install-signpath-module')
expect(installStep.run).toBeUndefined()
@@ -139,6 +139,34 @@ describe('Windows signing workflow contract', () => {
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
})
it('never recreates Windows signing requests on a workflow rerun', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const skipStep = steps.find((step) => step.name === 'Skip Windows artifact rebuild on rerun')
expect(skipStep?.if).toBe("matrix.platform == 'win' && github.run_attempt != 1")
expect(skipStep?.run).toContain('Existing signed release assets must be reused')
const signingStepNames = [
'Build Windows release artifacts',
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Download signed inner binaries from SignPath',
'Upload unsigned Windows installer for SignPath',
'Submit Windows installer signing request',
'Download signed Windows installer from SignPath',
'Stage signed Windows release assets',
'Publish signed Windows release artifacts'
]
for (const stepName of signingStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step?.if, stepName).toContain('github.run_attempt == 1')
}
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
+176
View File
@@ -0,0 +1,176 @@
# Files currently allowed to carry a `@ts-nocheck` header.
# This is a RATCHET: the list may only SHRINK. These exist only because the split
# runtime mixin chain cannot express forward references yet — do NOT add entries to
# get CI green; fix the types instead.
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
src/main/runtime/orca-runtime-activate-managed-worktree.ts
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
src/main/runtime/orca-runtime-agent-teams-launch-plan.ts
src/main/runtime/orca-runtime-apply-layout.ts
src/main/runtime/orca-runtime-apply-mobile-display-mode.ts
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
src/main/runtime/orca-runtime-apply-tracked-pty-title.ts
src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts
src/main/runtime/orca-runtime-attach-window.ts
src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
src/main/runtime/orca-runtime-core.ts
src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts
src/main/runtime/orca-runtime-create-agent-session.ts
src/main/runtime/orca-runtime-create-managed-remote-worktree.ts
src/main/runtime/orca-runtime-create-managed-worktree.ts
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts
src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-terminal-desktop.ts
src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts
src/main/runtime/orca-runtime-create-terminal.ts
src/main/runtime/orca-runtime-deliver-pending-messages.ts
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
src/main/runtime/orca-runtime-fence-automation-owner.ts
src/main/runtime/orca-runtime-file-commands.ts
src/main/runtime/orca-runtime-fit-override-listeners.ts
src/main/runtime/orca-runtime-focus-terminal.ts
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
src/main/runtime/orca-runtime-get-runtime-id.ts
src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts
src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
src/main/runtime/orca-runtime-get-worktree-ps.ts
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe-internal.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe.ts
src/main/runtime/orca-runtime-handle-mobile-unsubscribe.ts
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
src/main/runtime/orca-runtime-has-recent-terminal-output-path.ts
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
src/main/runtime/orca-runtime-linear-commands.ts
src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts
src/main/runtime/orca-runtime-list-managed-worktrees.ts
src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts
src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
src/main/runtime/orca-runtime-mobile-took-floor.ts
src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
src/main/runtime/orca-runtime-on-client-disconnected.ts
src/main/runtime/orca-runtime-on-pty-data.ts
src/main/runtime/orca-runtime-on-pty-exit.ts
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
src/main/runtime/orca-runtime-pick-most-recent-actor.ts
src/main/runtime/orca-runtime-postlude.ts
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
src/main/runtime/orca-runtime-reclaim-terminal-for-desktop.ts
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts
src/main/runtime/orca-runtime-record-pty-worktree.ts
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
src/main/runtime/orca-runtime-register-pty.ts
src/main/runtime/orca-runtime-remove-managed-worktree.ts
src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts
src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts
src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts
src/main/runtime/orca-runtime-resolve-exit-waiters.ts
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts
src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
src/main/runtime/orca-runtime-resolve-waiter.ts
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts
src/main/runtime/orca-runtime-resolve-worktree-selector.ts
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-runtime-id.ts
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts
src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts
src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts
src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
src/main/runtime/orca-runtime-split-terminal.ts
src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts
src/main/runtime/orca-runtime-state-fields.ts
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
src/main/runtime/orca-runtime-stop-structured-session-process.ts
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts
src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts
src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts
src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
src/main/runtime/orca-runtime-sync-window-graph.ts
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
src/main/runtime/orca-runtime-terminal-drivers.ts
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
src/main/runtime/orca-runtime-visible-snapshot-preview.ts
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts
src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts
src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts
src/main/runtime/runtime-browser-commands-browser-clear.ts
src/main/runtime/runtime-browser-commands-browser-click.ts
src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
src/main/runtime/runtime-browser-commands-browser-network-log.ts
src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts
src/main/runtime/runtime-browser-commands-browser-screencast.ts
src/main/runtime/runtime-browser-commands-browser-set-headers.ts
src/main/runtime/runtime-browser-commands-browser-tab-close.ts
src/main/runtime/runtime-browser-commands-browser-tab-create.ts
src/main/runtime/runtime-browser-commands-browser-tab-list.ts
src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
src/main/runtime/runtime-browser-commands-state.ts
src/main/runtime/runtime-file-command-host.ts
src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts
src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts
src/main/runtime/runtime-file-commands-constructor.ts
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts
src/main/runtime/runtime-file-commands-read-mobile-file.ts
src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts
src/main/runtime/runtime-file-commands-resolve-terminal-path.ts
src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts
src/main/runtime/runtime-file-commands-search-local-runtime-files.ts
src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts
src/main/runtime/runtime-file-commands-search-runtime-files.ts
src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts
src/main/runtime/runtime-file-commands-terminal-artifact-access.ts
src/main/runtime/runtime-file-commands-terminal-file-paths.ts
src/main/runtime/runtime-file-commands-write-file-explorer-file.ts
src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts
src/main/runtime/runtime-file-watcher-leases.ts
+2
View File
@@ -10,10 +10,12 @@
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-display-name.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/ipc/worktree-path-comparison.ts",
"../src/main/wsl-availability.ts",
"../src/main/wsl-directory-probe-command.ts",
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl-running-distro-cache.ts",
+7 -3
View File
@@ -3,18 +3,22 @@ title: How to use GLM-5.2 in Orca ADE
description: Configure Claude Code and other CLI agent harnesses to run GLM-5.2 inside Orca worktrees.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
GLM-5.2 works in Orca through the agent harness you already use. Configure GLM-5.2 in Claude Code, OpenCode, Cline, Kilo Code, Roo Code, Droid, OpenClaw, or another CLI agent, then launch that agent from Orca's picker.
Orca supplies the isolated worktree, terminal panes, browser tab, review flow, and session management. Your [Z.ai CodePlan subscription](https://z.ai/subscribe) and agent config supply the model access.
<Callout title="Prerequisite">
You need an active [Z.ai CodePlan subscription](https://z.ai/subscribe) with GLM Coding Plan access before configuring GLM-5.2 in an agent harness. OpenAI-compatible harnesses also need a Z.ai API key. Orca does not include or resell GLM access.
You need an active [Z.ai CodePlan subscription](https://z.ai/subscribe) with GLM Coding Plan
access before configuring GLM-5.2 in an agent harness. OpenAI-compatible harnesses also need a
Z.ai API key. Orca does not include or resell GLM access.
</Callout>
<Callout title="Source">
This page documents the GLM-5.2 configuration tested with Orca. Z.ai's [model guide](https://docs.z.ai/devpack/latest-model) may list newer models; verify model names, context limits, and harness compatibility there before substituting one.
This page documents the GLM-5.2 configuration tested with Orca. Z.ai's [model
guide](https://docs.z.ai/devpack/latest-model) may list newer models; verify model names, context
limits, and harness compatibility there before substituting one.
</Callout>
## Claude Code
@@ -3,12 +3,13 @@ title: Agent hibernation
description: Let Orca pause idle background agent terminals and auto-resume them when you reopen the worktree.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
When you keep dozens of worktrees open, idle agents add up — each one is a live PTY holding a model session in memory. Agent hibernation lets Orca quietly stop those terminals once they've been done and untouched long enough, then resume the same session the next time you open the worktree.
<Callout title="Experimental">
Agent hibernation is off by default. Turn it on under **Settings → Experimental → Agent hibernation** while we keep tuning the safety model.
Agent hibernation is off by default. Turn it on under **Settings → Experimental → Agent
hibernation** while we keep tuning the safety model.
</Callout>
## What gets hibernated
@@ -2,7 +2,7 @@
title: Agent hooks & memory
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca plays nicely with the agent hook and memory conventions Claude Code and Codex already use — it reads them, respects them, and gives you a UI for the ones that make sense in an IDE context.
@@ -27,5 +27,6 @@ Claude's `CLAUDE.md` and Codex's `AGENTS.md` (at repo root or nested) are left a
Hook endpoints are written to disk (`{userData}/agent-hooks/endpoint.env` on POSIX, `endpoint.cmd` on Windows) and re-sourced on every hook invocation, so long-lived agent sessions keep reaching the live Orca server even after an app restart — no more dead-port POSTs from a PTY that outlived the previous session.
<Callout>
The Orca CLI exposes a commented worktree status field agents can update themselves. See [Worktree checkpoints](/docs/cli/worktree-checkpoints).
The Orca CLI exposes a commented worktree status field agents can update themselves. See [Worktree
checkpoints](/docs/cli/worktree-checkpoints).
</Callout>
@@ -3,7 +3,7 @@ title: Chat UI (native chat)
description: Optional chat surface over supported agent terminals — skills, model pickers, and transcript view.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Chat UI is an experimental view layered on supported agent terminal sessions. The terminal remains the source of truth; Chat UI is a structured transcript + composer for the same PTY. Transcript decoding covers **Claude**, **Codex**, **Grok**, and **OMP** — OMP sessions open in Chat UI like the others instead of staying raw-terminal-only.
@@ -30,5 +30,6 @@ When Claude shows an **AskUserQuestion** (or similar structured permission/quest
Chat UI ships on desktop for supported local and remote (paired server) agent sessions. The [mobile companion](/docs/mobile) reuses chat-style transcript patterns for the same paired sessions.
<Callout title="Experimental">
Transcript fidelity, streaming, and terminal parity are still under active tuning. Prefer the raw TUI when you need every OSC/status detail.
Transcript fidelity, streaming, and terminal parity are still under active tuning. Prefer the raw
TUI when you need every OSC/status detail.
</Callout>
@@ -3,7 +3,7 @@ title: Agent session history
description: Browse and resume past Claude, Codex, Cursor, Gemini, and other agent sessions from Orca's right sidebar.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca scans the on-disk session transcripts that supported agent CLIs leave behind and lists them in a right-sidebar panel called **Agent Session History**. Pick a past session, click **Resume**, and Orca runs the agent's resume command in a fresh terminal — same `cwd`, same session ID, no manual `--resume` flag wrangling.
@@ -39,13 +39,16 @@ Click a session row to open its details: working directory, branch, model, messa
- **Resume** — opens a new terminal in the session's `cwd` and runs the agent's resume command (e.g. `claude --resume <id>`, `codex resume <id>`, `pi --session <session_file>`, `prime-agent --resume <path>`, `cursor-agent --resume <id>`, `acli rovodev run --restore <id>`). Codex sessions also re-export `CODEX_HOME` when the original session set one.
Pi resumes from the on-disk session file reported by its hooks (`--session <path>`), not from a bare session id. If that file is missing, Resume is unavailable for that row even when a session id exists.
- **Copy resume command** — copies the same shell command to the clipboard for use in an external terminal.
- **Copy session ID** / **Copy log path** — for scripting or attaching transcripts to bug reports.
- **Open log** / **Reveal log** — open the raw transcript file in Orca, or jump to it in your OS file manager.
- **Open cwd** — open the session's working directory as a workspace.
<Callout title="Resume needs a local workspace">
Resume runs the agent CLI on the machine where Orca is rendering. If you're connected to a remote workspace, switch back to a local one (or use **Copy resume command** and run it on the remote yourself) before clicking **Resume**.
Resume runs the agent CLI on the machine where Orca is rendering. If you're connected to a remote
workspace, switch back to a local one (or use **Copy resume command** and run it on the remote
yourself) before clicking **Resume**.
</Callout>
## Where the transcripts come from
+42 -39
View File
@@ -3,12 +3,15 @@ title: Supported agents
description: Every agent Orca ships with out of the box.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca works with **any CLI agent** — the agent combobox just launches a process in a terminal. The following ship preconfigured in the built-in agent picker with one-click launch/setup; deeper hooks, status, usage tracking, and account switching are noted where supported.
<Callout title="Permission safety">
The defaults below pass each agent's permission-bypass flag for new launches. A worktree is an isolated checkout, not a security sandbox: the agent can still access files and network resources available to its process. Choose **Manual** in **Settings → Agents → Agent Permissions** unless you intentionally trust the agent and the task.
The defaults below pass each agent's permission-bypass flag for new launches. A worktree is an
isolated checkout, not a security sandbox: the agent can still access files and network resources
available to its process. Choose **Manual** in **Settings → Agents → Agent Permissions** unless
you intentionally trust the agent and the task.
</Callout>
## Permissions default
@@ -19,40 +22,40 @@ Use **Settings → Agents → Agent Permissions** when you want to switch all un
To restore prompts for one agent only, edit that agent's default arguments or environment in Settings. Orca treats a non-empty custom value as an explicit override and opts that agent out of future permission-mode migrations.
| Agent | Notes | Docs |
| --- | --- | --- |
| Claude Code | Deep integration: usage, hot-swap, hooks | [Anthropic](https://docs.anthropic.com/claude/docs/claude-code) |
| Claude Agent Teams | Disabled by default — enable under Settings → Agents to launch via `orca claude-teams` with native panes for each teammate | [Anthropic](https://code.claude.com/docs/agent-teams) |
| Codex | Deep integration: usage, hot-swap | [OpenAI](https://github.com/openai/codex) |
| Grok | Auto-setup | [xAI](https://x.ai/cli) |
| GitHub Copilot CLI | Auto-setup | [GitHub](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) |
| OpenCode | Auto-setup, status | [OpenCode](https://opencode.ai/docs/cli/) |
| Pi | Auto-setup, hooks, status | [Pi](https://pi.dev) |
| OMP | Auto-setup, hooks, status | [OMP](https://omp.sh) |
| Prime Agent | Auto-setup, hooks, status, session history | [Prime Intellect](https://github.com/PrimeIntellect-ai/prime-agent) |
| Gemini | Auto-setup | [Google](https://github.com/google-gemini/gemini-cli) |
| Antigravity | Auto-setup, hooks, status | [Google](https://antigravity.google/docs/cli-overview) |
| Ante | Auto-setup, status | [Ante](https://github.com/AntigmaLabs/ante-preview) |
| Aider | Auto-setup | [Aider](https://aider.chat/docs/) |
| Goose | Auto-setup | [Block](https://block.github.io/goose/docs/quickstart/) |
| Amp | Auto-setup | [Amp](https://ampcode.com/manual#install) |
| Kilocode | Auto-setup | [Kilo](https://kilo.ai/docs/cli) |
| Kiro | Auto-setup | [Kiro](https://kiro.dev/docs/cli/) |
| Charm Crush | Auto-setup | [Charm](https://github.com/charmbracelet/crush) |
| Auggie | Auto-setup | [Augment](https://docs.augmentcode.com/cli/overview) |
| Autohand | Auto-setup | [Autohand](https://github.com/autohandai/code-cli) |
| Cline | Auto-setup | [Cline](https://docs.cline.bot/cline-cli/overview) |
| Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) |
| Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) |
| Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) |
| Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) |
| Devin | Auto-setup | [Devin](https://devin.ai/cli) |
| Droid (Factory) | Auto-setup, hooks, status | [Factory](https://docs.factory.ai/cli/getting-started/quickstart) |
| Kimi | Auto-setup | [Moonshot](https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html) |
| Mistral Vibe | Auto-setup | [Mistral](https://github.com/mistralai/mistral-vibe) |
| MiniMax | Auto-setup, usage tracking, rate-limit tracking | [MiniMax](https://www.minimax.chat) |
| Qwen Code | Auto-setup via the installed `qwen` executable | [Qwen](https://github.com/QwenLM/qwen-code) |
| Rovo Dev | Auto-setup | [Atlassian](https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/) |
| Hermes | Auto-setup | [Nous](https://hermes-agent.nousresearch.com/docs/) |
| OpenClaw | Auto-setup | [OpenClaw](https://github.com/openclaw/openclaw) |
| Trae | Auto-setup via `traecli` (TRAE CN CLI) | [Trae](https://www.trae.ai/) |
| Agent | Notes | Docs |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Claude Code | Deep integration: usage, hot-swap, hooks | [Anthropic](https://docs.anthropic.com/claude/docs/claude-code) |
| Claude Agent Teams | Disabled by default — enable under Settings → Agents to launch via `orca claude-teams` with native panes for each teammate | [Anthropic](https://code.claude.com/docs/agent-teams) |
| Codex | Deep integration: usage, hot-swap | [OpenAI](https://github.com/openai/codex) |
| Grok | Auto-setup | [xAI](https://x.ai/cli) |
| GitHub Copilot CLI | Auto-setup | [GitHub](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) |
| OpenCode | Auto-setup, status | [OpenCode](https://opencode.ai/docs/cli/) |
| Pi | Auto-setup, hooks, status | [Pi](https://pi.dev) |
| OMP | Auto-setup, hooks, status | [OMP](https://omp.sh) |
| Prime Agent | Auto-setup, hooks, status, session history | [Prime Intellect](https://github.com/PrimeIntellect-ai/prime-agent) |
| Gemini | Auto-setup | [Google](https://github.com/google-gemini/gemini-cli) |
| Antigravity | Auto-setup, hooks, status | [Google](https://antigravity.google/docs/cli-overview) |
| Ante | Auto-setup, status | [Ante](https://github.com/AntigmaLabs/ante-preview) |
| Aider | Auto-setup | [Aider](https://aider.chat/docs/) |
| Goose | Auto-setup | [Block](https://block.github.io/goose/docs/quickstart/) |
| Amp | Auto-setup | [Amp](https://ampcode.com/manual#install) |
| Kilocode | Auto-setup | [Kilo](https://kilo.ai/docs/cli) |
| Kiro | Auto-setup | [Kiro](https://kiro.dev/docs/cli/) |
| Charm Crush | Auto-setup | [Charm](https://github.com/charmbracelet/crush) |
| Auggie | Auto-setup | [Augment](https://docs.augmentcode.com/cli/overview) |
| Autohand | Auto-setup | [Autohand](https://github.com/autohandai/code-cli) |
| Cline | Auto-setup | [Cline](https://docs.cline.bot/cline-cli/overview) |
| Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) |
| Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) |
| Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) |
| Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) |
| Devin | Auto-setup | [Devin](https://devin.ai/cli) |
| Droid (Factory) | Auto-setup, hooks, status | [Factory](https://docs.factory.ai/cli/getting-started/quickstart) |
| Kimi | Auto-setup | [Moonshot](https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html) |
| Mistral Vibe | Auto-setup | [Mistral](https://github.com/mistralai/mistral-vibe) |
| MiniMax | Auto-setup, usage tracking, rate-limit tracking | [MiniMax](https://www.minimax.chat) |
| Qwen Code | Auto-setup via the installed `qwen` executable | [Qwen](https://github.com/QwenLM/qwen-code) |
| Rovo Dev | Auto-setup | [Atlassian](https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/) |
| Hermes | Auto-setup | [Nous](https://hermes-agent.nousresearch.com/docs/) |
| OpenClaw | Auto-setup | [OpenClaw](https://github.com/openclaw/openclaw) |
| Trae | Auto-setup via `traecli` (TRAE CN CLI) | [Trae](https://www.trae.ai/) |
@@ -16,7 +16,7 @@ Orca reads the local usage state each agent maintains on disk (under `~/.claude`
## Multi-account accounting
The status bar always reflects the *active* account. Other configured accounts are visible in the account switcher with their own usage.
The status bar always reflects the _active_ account. Other configured accounts are visible in the account switcher with their own usage.
## Usage roster
@@ -2,11 +2,14 @@
title: Design Mode
---
import { ImagePlaceholder } from '@/components/docs/prose';
import { ImagePlaceholder } from '@/components/docs/prose'
Design Mode turns the Orca browser into a pointer-to-code tool. Toggle it on, click any UI element on the rendered page, and the element drops into the agent chat as rich context — with its DOM, computed styles, and a screenshot.
<ImagePlaceholder src="/docs/orca-design-mode.gif" caption="Design Mode: click a button, it lands in the agent chat" />
<ImagePlaceholder
src="/docs/orca-design-mode.gif"
caption="Design Mode: click a button, it lands in the agent chat"
/>
## Turn it on
+5 -2
View File
@@ -2,11 +2,14 @@
title: Per-worktree browser
---
import { ImagePlaceholder } from '@/components/docs/prose';
import { ImagePlaceholder } from '@/components/docs/prose'
Every Orca worktree has its own browser. It's a real Chromium window — address bar, history, devtools — embedded in a pane. Tabs are scoped to the worktree, so the app you're building against stays out of the way of your other work.
<ImagePlaceholder src="/docs/orca-design-mode.gif" caption="Per-worktree browser pane with address bar and tab strip" />
<ImagePlaceholder
src="/docs/orca-design-mode.gif"
caption="Per-worktree browser pane with address bar and tab strip"
/>
## Controls
+4 -2
View File
@@ -3,12 +3,14 @@ title: Computer use
description: Drive local desktop apps from an agent via accessibility trees, screenshots, and safe UI actions.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
The `orca computer` CLI lets an agent inspect and control native desktop apps — list running apps, read accessibility trees, click controls, set values, type text, scroll, and take screenshots. Use it when a task needs to operate the OS or a third-party app rather than a terminal or the built-in browser.
<Callout title="Beta">
Computer use ships native helpers per platform and requires Accessibility (and on macOS, Screen Recording) permission. The command surface is stable enough for skills to build against, but flag names may still shift.
Computer use ships native helpers per platform and requires Accessibility (and on macOS, Screen
Recording) permission. The command surface is stable enough for skills to build against, but flag
names may still shift.
</Callout>
## First-time setup
+6 -3
View File
@@ -3,18 +3,21 @@ title: Orchestration
description: Coordinate agents with Runs, tasks, supervised workers, messages, and decision gates.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orchestration is Orca's structured multi-agent layer: a **Run** (namespace + coordinator inbox), **Tasks**, **Dispatches**, supervised **workers**, messages, and decision gates.
Use it when you need ownership, completion tracking, or a DAG. For one-off prompts, use `orca terminal send`. For full ownership handoffs without supervision, use worktree/terminal commands from the `orca-cli` skill.
<Callout title="Experimental">
Enable orchestration under Settings → Experimental before using these commands. The CLI talks to the running Orca runtime, so `orca status --json` should succeed first.
Enable orchestration under Settings → Experimental before using these commands. The CLI talks to
the running Orca runtime, so `orca status --json` should succeed first.
</Callout>
<Callout title="Legacy commands retired">
`orca orchestration run` and `run-stop` (and `coordinator-start` / `coordinator-stop`) perform **no effects**. They return recovery text pointing at `orca skills get orchestration --full`. Use the Run + worker-start flow below.
`orca orchestration run` and `run-stop` (and `coordinator-start` / `coordinator-stop`) perform
**no effects**. They return recovery text pointing at `orca skills get orchestration --full`. Use
the Run + worker-start flow below.
</Callout>
## Core model
+4 -2
View File
@@ -10,7 +10,7 @@ keywords:
- agent CLI
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents.
@@ -118,5 +118,7 @@ orca emulator kill --json
Use `--worktree <selector>`, `--device <udid-or-name>`, or `--emulator <id>` when a script needs an explicit target.
<Callout>
For the full command surface including tabs, waits, cookies, and frames, see [Orca CLI reference](/docs/cli/reference), then install the Orca CLI skill (see [Skills registry](/docs/cli/skills)) and point your agent at it.
For the full command surface including tabs, waits, cookies, and frames, see [Orca CLI
reference](/docs/cli/reference), then install the Orca CLI skill (see [Skills
registry](/docs/cli/skills)) and point your agent at it.
</Callout>
+3 -2
View File
@@ -3,7 +3,7 @@ title: Orca CLI reference
description: Commands, selectors, and agent-friendly patterns for driving Orca from a shell.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
The `orca` CLI talks to a running Orca runtime. Use it when a shell script or agent needs to inspect worktrees, launch terminals, open files, automate the built-in browser, or report progress back into Orca.
@@ -116,7 +116,8 @@ orca terminal close --terminal <handle> --json
Omit `--terminal` to target the active terminal in the current worktree. Read before sending when you are not sure what the terminal is waiting for.
<Callout title="Terminal handles">
Terminal handles are runtime-scoped. If Orca restarts or a command reports a stale terminal handle, run `orca terminal list --json` and reacquire the handle.
Terminal handles are runtime-scoped. If Orca restarts or a command reports a stale terminal
handle, run `orca terminal list --json` and reacquire the handle.
</Callout>
`terminal list` reports each terminal's `executionHostId` when Orca can verify it, plus a result-level `hostScope` with covered and omitted host IDs. Treat a missing host identity or scope as **unverifiable**, not local. A missing terminal is evidence that it exited only when its execution host is listed in `hostScope.hostIds`.
+10 -10
View File
@@ -12,21 +12,21 @@ keywords:
- orca-emulator-android skill
---
Orca ships **skills** that agents install into their skill directories. Public install packages are **hybrid discovery stubs**: short `SKILL.md` files that tell the agent *when* to engage Orca and how to load the full guide from the running CLI. Command flags live in the binary so they cannot drift from the app version.
Orca ships **skills** that agents install into their skill directories. Public install packages are **hybrid discovery stubs**: short `SKILL.md` files that tell the agent _when_ to engage Orca and how to load the full guide from the running CLI. Command flags live in the binary so they cannot drift from the app version.
## Installable Orca skills
Use `npx skills add` with the public Orca repo and the skill name. Default agent setup usually installs `orca-cli`, `computer-use`, and `orchestration`.
| Skill | Install | Use it for |
| --- | --- | --- |
| [`orca-cli`](#orca-cli) | `npx skills add https://github.com/stablyai/orca --skill orca-cli --global` | Worktrees, terminals, files, automations, embedded browser. |
| [`orchestration`](#orchestration) | `npx skills add https://github.com/stablyai/orca --skill orchestration --global` | Multi-agent Runs, tasks, supervised workers, messages, gates. |
| [`computer-use`](#computer-use) | `npx skills add https://github.com/stablyai/orca --skill computer-use --global` | Desktop apps via accessibility trees and safe UI actions. |
| [`orca-linear`](#orca-linear) | `npx skills add https://github.com/stablyai/orca --skill orca-linear --global` | Linear ticket read/write through `orca linear`. |
| [`orca-emulator`](#orca-emulator) | `npx skills add https://github.com/stablyai/orca --skill orca-emulator --global` | iOS Simulator control. |
| [`orca-emulator-android`](#orca-emulator-android) | `npx skills add https://github.com/stablyai/orca --skill orca-emulator-android --global` | Android emulator/device via adb. |
| [`orca-per-workspace-env`](#orca-per-workspace-env) | `npx skills add https://github.com/stablyai/orca --skill orca-per-workspace-env --global` | Per-workspace environment recipes (`orca.yaml`). |
| Skill | Install | Use it for |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| [`orca-cli`](#orca-cli) | `npx skills add https://github.com/stablyai/orca --skill orca-cli --global` | Worktrees, terminals, files, automations, embedded browser. |
| [`orchestration`](#orchestration) | `npx skills add https://github.com/stablyai/orca --skill orchestration --global` | Multi-agent Runs, tasks, supervised workers, messages, gates. |
| [`computer-use`](#computer-use) | `npx skills add https://github.com/stablyai/orca --skill computer-use --global` | Desktop apps via accessibility trees and safe UI actions. |
| [`orca-linear`](#orca-linear) | `npx skills add https://github.com/stablyai/orca --skill orca-linear --global` | Linear ticket read/write through `orca linear`. |
| [`orca-emulator`](#orca-emulator) | `npx skills add https://github.com/stablyai/orca --skill orca-emulator --global` | iOS Simulator control. |
| [`orca-emulator-android`](#orca-emulator-android) | `npx skills add https://github.com/stablyai/orca --skill orca-emulator-android --global` | Android emulator/device via adb. |
| [`orca-per-workspace-env`](#orca-per-workspace-env) | `npx skills add https://github.com/stablyai/orca --skill orca-per-workspace-env --global` | Per-workspace environment recipes (`orca.yaml`). |
## Hybrid stubs vs the live guide
+6 -6
View File
@@ -34,12 +34,12 @@ YAML and TOML front matter is shown in the rich editor and rendered preview by d
In rich markdown tables:
| Key | Behavior |
| --- | --- |
| **Tab** / **Shift-Tab** | Next / previous cell; Tab past the last cell inserts a row |
| **Enter** | Move to the cell below; on the last row, add a row |
| **Backspace** on a fully empty row | Delete the row (or the whole table if it is the last row) |
| **Backspace** in an empty cell when the row still has content | Step to the previous cell |
| Key | Behavior |
| ------------------------------------------------------------- | ---------------------------------------------------------- |
| **Tab** / **Shift-Tab** | Next / previous cell; Tab past the last cell inserts a row |
| **Enter** | Move to the cell below; on the last row, add a row |
| **Backspace** on a fully empty row | Delete the row (or the whole table if it is the last row) |
| **Backspace** in an empty cell when the row still has content | Step to the previous cell |
Use **Shift-Tab** to unindent a list item or the selected lines of a code block.
+3 -2
View File
@@ -2,7 +2,7 @@
title: HTML, Mermaid, PDF & image viewers
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca includes built-in viewers for the formats that show up in most repos.
@@ -35,5 +35,6 @@ Scroll, zoom, and text selection. Useful for design docs checked into the repo.
`.ipynb` files open in a notebook viewer with rendered markdown, syntax-highlighted code cells, and saved outputs. Editing cells writes back to the on-disk `.ipynb` while preserving nbformat, so diffs stay clean.
<Callout title="Beta">
The notebook editor is marked beta. Cell execution and richer output rendering are still settling — file an issue if a notebook in your repo doesn't load cleanly.
The notebook editor is marked beta. Cell execution and richer output rendering are still settling
— file an issue if a notebook in your repo doesn't load cleanly.
</Callout>
+3 -2
View File
@@ -3,7 +3,7 @@ title: Your first 3-agent session
description: From empty app to three agents running in parallel in under five minutes.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
This is the single most important page in the docs. By the end you'll have three agents running in parallel on three different approaches to the same task, with one PR shipped.
@@ -48,5 +48,6 @@ Once agents settle, open each worktree's diff view. Use [Annotate AI Diff](/docs
Commit and push directly from Orca — see [Commit & push from Orca](/docs/review/commit-push). The other two worktrees can be deleted with one click; their branches go with them.
<Callout title="That's it">
This flow — add → worktree → agent → split → diff → ship — is the whole of Orca. Every other page in these docs is a deeper look at one of those steps.
This flow — add → worktree → agent → split → diff → ship — is the whole of Orca. Every other page
in these docs is a deeper look at one of those steps.
</Callout>
+18 -18
View File
@@ -9,14 +9,14 @@ This page covers the errors you’ll see most often and how to fix them.
## Quick triage
| What you see | Likely cause | First thing to try |
| --- | --- | --- |
| “GitHub is rate-limiting requests” / “rate limit exceeded (core)” | GitHub REST (core) quota exhausted for your user | Wait for reset; stop extra `gh` / agent / Orca usage; check [Settings → Git → GitHub API Budget](/docs/settings) |
| “GitHub authentication is unavailable” / `gh auth` prompts | `gh` not logged in, expired token, or bad `GITHUB_TOKEN` | `gh auth status`, then `gh auth login` |
| “GitHub did not allow access” / HTTP 403 (not rate limit) | Missing scopes or no access to the repo | Re-auth with `repo` (and needed org SSO); confirm you can open the PR in the browser |
| “repository is unavailable” / HTTP 404 | Wrong remote, private repo without access, or renamed repo | Check `git remote -v` and browser access |
| “GitHub is unreachable” / timeouts | Network, proxy, VPN, or GitHub outage | Check [githubstatus.com](https://www.githubstatus.com/); retry off VPN |
| “GitHub CLI is unavailable” | `gh` missing from PATH Orca uses | Install `gh` and restart Orca |
| What you see | Likely cause | First thing to try |
| ----------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| “GitHub is rate-limiting requests” / “rate limit exceeded (core)” | GitHub REST (core) quota exhausted for your user | Wait for reset; stop extra `gh` / agent / Orca usage; check [Settings → Git → GitHub API Budget](/docs/settings) |
| “GitHub authentication is unavailable” / `gh auth` prompts | `gh` not logged in, expired token, or bad `GITHUB_TOKEN` | `gh auth status`, then `gh auth login` |
| “GitHub did not allow access” / HTTP 403 (not rate limit) | Missing scopes or no access to the repo | Re-auth with `repo` (and needed org SSO); confirm you can open the PR in the browser |
| “repository is unavailable” / HTTP 404 | Wrong remote, private repo without access, or renamed repo | Check `git remote -v` and browser access |
| “GitHub is unreachable” / timeouts | Network, proxy, VPN, or GitHub outage | Check [githubstatus.com](https://www.githubstatus.com/); retry off VPN |
| “GitHub CLI is unavailable” | `gh` missing from PATH Orca uses | Install `gh` and restart Orca |
## Rate limits (most common)
@@ -24,11 +24,11 @@ GitHub gives each **authenticated user** a shared hourly budget. **Every tool on
### Buckets Orca cares about
| Bucket | What it covers | Typical limit (authenticated) |
| --- | --- | --- |
| **REST (core)** | Most PR/issue/API calls (`gh pr view`, checks metadata, many REST endpoints) | 5,000 / hour |
| **GraphQL** | Project/Tasks and some richer PR queries | 5,000 points / hour |
| **Search** | Search-driven lists | 30 / minute |
| Bucket | What it covers | Typical limit (authenticated) |
| --------------- | ---------------------------------------------------------------------------- | ----------------------------- |
| **REST (core)** | Most PR/issue/API calls (`gh pr view`, checks metadata, many REST endpoints) | 5,000 / hour |
| **GraphQL** | Project/Tasks and some richer PR queries | 5,000 points / hour |
| **Search** | Search-driven lists | 30 / minute |
When a primary bucket is exhausted, GitHub returns HTTP **403** with a message like `API rate limit exceeded`. Orca classifies that as rate-limited, keeps the last known PR status when it can, and **stops spawning more `gh` calls** for a short window so a single limit doesn’t turn into a storm of failures.
@@ -106,11 +106,11 @@ GitHub auth is **per host**. Logging in on your laptop does not log in `gh` on a
## Permission and repository errors
| Symptom | Meaning |
| --- | --- |
| HTTP 403 without “rate limit” | Token lacks scope or you’re not allowed to see the resource |
| HTTP 404 / “could not resolve to a Repository” | Repo missing, renamed, or invisible to this token |
| “resource not accessible by integration” | App/token type can’t perform that action |
| Symptom | Meaning |
| ---------------------------------------------- | ----------------------------------------------------------- |
| HTTP 403 without “rate limit” | Token lacks scope or you’re not allowed to see the resource |
| HTTP 404 / “could not resolve to a Repository” | Repo missing, renamed, or invisible to this token |
| “resource not accessible by integration” | App/token type can’t perform that action |
Fixes:
+5 -3
View File
@@ -1,9 +1,9 @@
---
title: What is Orca?
description: "A 60-second pitch: who Orca is for and when to reach for it."
description: 'A 60-second pitch: who Orca is for and when to reach for it.'
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca is a desktop IDE for running multiple AI coding agents side by side. Every task gets its own git worktree, its own agent terminal, and its own browser tab — so you can fan out work across Claude Code, Codex, Cursor CLI, and friends without stashing, branch-juggling, or losing flow.
@@ -25,5 +25,7 @@ Orca is designed for people who already write code for a living and want to use
- **Not a hosted VPS product.** Orca runs on your desktop by default. Remote compute uses machines and cloud accounts you control — [SSH targets](/docs/ssh), [self-hosted Orca servers](/docs/remote-servers), or [Cloud VMs / per-workspace environments](/docs/ways-to-run#4-cloud-vms-per-workspace-environments).
<Callout title="Next steps">
Head to [Install](/docs/install), then walk through [Your first 3-agent session](/docs/first-session) — the single most important page in these docs. When you're ready to move agents off the laptop, start with [Ways to run Orca](/docs/ways-to-run).
Head to [Install](/docs/install), then walk through [Your first 3-agent
session](/docs/first-session) — the single most important page in these docs. When you're ready to
move agents off the laptop, start with [Ways to run Orca](/docs/ways-to-run).
</Callout>
+18 -13
View File
@@ -3,7 +3,7 @@ title: Install
description: Download Orca for macOS, Windows, or Linux, and opt into RC builds.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
## Download
@@ -21,17 +21,20 @@ import { Callout } from '@/components/docs/prose';
<ul className="hidden md:block">
<li>
**macOS:** [Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg)
**macOS:** [Apple
Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) ·
[Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg)
</li>
<li>
**Windows:** [installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
**Windows:**
[installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
</li>
<li>
**Linux:** [AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [.deb](https://github.com/stablyai/orca/releases)
</li>
<li>
Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).
**Linux:**
[AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases)
</li>
<li>Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).</li>
</ul>
### Homebrew (macOS)
@@ -58,16 +61,18 @@ Orca auto-updates by default, tracking the **stable** channel. Stable releases a
There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu):
| Modifier | Effect |
| --- | --- |
| **Shift+click** | Include the latest **RC** prerelease |
| **Cmd+click** (macOS) / **Ctrl+click** (Windows/Linux) | Latest **perf**-tagged prerelease |
| **Option+click** (macOS only) | Pick a **validated local macOS build** that passes Orca’s compatibility checks |
| Modifier | Effect |
| ------------------------------------------------------ | ------------------------------------------------------------------------------ |
| **Shift+click** | Include the latest **RC** prerelease |
| **Cmd+click** (macOS) / **Ctrl+click** (Windows/Linux) | Latest **perf**-tagged prerelease |
| **Option+click** (macOS only) | Pick a **validated local macOS build** that passes Orca’s compatibility checks |
You can still download any build directly from the [GitHub Releases page](https://github.com/stablyai/orca/releases).
<Callout title="Don't like the current update">
Older versions are always available on the [GitHub Releases page](https://github.com/stablyai/orca/releases). Orca will not force-downgrade your worktree data if you go back.
Older versions are always available on the [GitHub Releases
page](https://github.com/stablyai/orca/releases). Orca will not force-downgrade your worktree data
if you go back.
</Callout>
## Platform notes
+9 -3
View File
@@ -3,12 +3,15 @@ title: Mobile companion
description: Pair the Orca mobile app to your desktop to monitor agents and unstick worktrees from your phone.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
The Orca mobile companion is an iOS/Android app that pairs with your desktop Orca and gives you a read-mostly view of running agents — agent status, recent terminal scrollback, and the controls you actually want from a phone (replying to a prompt, sleeping a worktree, reviewing source control, switching agent accounts). Pairing is one-time and the desktop is always the source of truth.
<Callout title="Beta">
The mobile companion is in beta. Install iOS from the [App Store](https://apps.apple.com/us/app/orca-ide/id6766130217), join the [TestFlight preview channel](https://testflight.apple.com/join/YjeGMQBA), or install Android from the [current APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk).
The mobile companion is in beta. Install iOS from the [App
Store](https://apps.apple.com/us/app/orca-ide/id6766130217), join the [TestFlight preview
channel](https://testflight.apple.com/join/YjeGMQBA), or install Android from the [current APK
0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk).
</Callout>
## What you can do from mobile
@@ -110,5 +113,8 @@ The mobile terminal has a dedicated **Terminal settings** screen (Settings → T
- **Can't reach desktop** — phone and desktop must share a network path (LAN, Tailscale, or the pairing path you used). Closing the desktop app drops the connection; reopen desktop and the phone reconnects automatically.
<Callout title="Next steps">
Pair mobile notifications with [desktop notifications](/docs/notifications) so agent-finished alerts reach the right device. For headless machines, use [Remote Orca Server](/docs/remote-servers) mobile pairing. Track provider usage on desktop and mobile under [Usage & rate-limit tracking](/docs/agents/usage-tracking).
Pair mobile notifications with [desktop notifications](/docs/notifications) so agent-finished
alerts reach the right device. For headless machines, use [Remote Orca
Server](/docs/remote-servers) mobile pairing. Track provider usage on desktop and mobile under
[Usage & rate-limit tracking](/docs/agents/usage-tracking).
</Callout>
@@ -3,7 +3,7 @@ title: Agents & sessions
description: State dots, restart chips, and the lifecycle of an agent session.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
An **agent session** is one CLI agent running in one terminal in one worktree. Orca tracks its lifecycle so you always know which sessions are working and which are idle — without you having to click into each tab to check.
@@ -76,5 +76,6 @@ When an agent exits (clean or crash), the tab shows a **Restart** chip. One clic
1. **Exit** — the process ends; the Restart chip appears.
<Callout>
For the exact detection rules, see the `terminal wait --for tui-idle` command in the [Orca CLI](/docs/cli/overview).
For the exact detection rules, see the `terminal wait --for tui-idle` command in the [Orca
CLI](/docs/cli/overview).
</Callout>
+5 -7
View File
@@ -3,7 +3,7 @@ title: Quick Open & Jump Palette
description: Cmd-J scoped jump across worktrees, recents, and tabs.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Once you have more than a handful of worktrees, navigation becomes the bottleneck. Orca ships two keyboard-first navigation tools.
@@ -13,13 +13,13 @@ File search scoped to the current worktree. Type a fragment; Orca ranks by recen
## New-tab omnibox
The tab strip **+** omnibox searches **open tabs**, files, URLs, and agents in one field (placeholder: *Search open tabs, files, URLs, agents…*). File rows use the same filename-first layout as Quick Open. Matching an already-open editor tab prefers that tab over a duplicate file result, so you jump to the open buffer instead of opening a second copy.
The tab strip **+** omnibox searches **open tabs**, files, URLs, and agents in one field (placeholder: _Search open tabs, files, URLs, agents…_). File rows use the same filename-first layout as Quick Open. Matching an already-open editor tab prefers that tab over a duplicate file result, so you jump to the open buffer instead of opening a second copy.
Type a web search instead of a path or URL to open it in the worktree browser with your [Default Search Engine](/docs/settings). A single token still ranks file matches first; a multi-word phrase promotes the search row. Prefix the query with `?` to skip file and tab matching and search immediately.
## Worktree Jump Palette (Cmd-J)
Jump across every worktree and every tab in one search. The placeholder in the empty input reads *repo/worktree* — type either half and Orca filters accordingly. Once you start typing, search includes non-archived worktrees even if they are hidden by the sidebar's current filters. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
Jump across every worktree and every tab in one search. The placeholder in the empty input reads _repo/worktree_ — type either half and Orca filters accordingly. Once you start typing, search includes non-archived worktrees even if they are hidden by the sidebar's current filters. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
Press **Tab** in the palette for a host and project filter menu. Selected hosts and projects narrow the result set and show as chips you can remove one at a time; closing the palette clears the filter so the next open is unscoped.
@@ -32,12 +32,10 @@ Results include:
- Worktrees matched by cached GitHub PR title or number (`#123`) and cached GitLab merge request title or number (`!123`) when that review metadata is already available.
- Every open tab, scoped first by current worktree, then globally. A match in the tab's title or content ranks ahead of a match only in its worktree, branch, or repo; equally strong matches prefer the tab you used more recently. Rows show the last-active age when Orca has one. Type aliases such as `terminal` or `simulator` still match those tab types without cluttering the row label.
When a typed query hits **both** open tabs and worktrees, the palette interleaves a short preview of each section so neither primary list is buried. If a section has more matches, click **See more** in its *N more* row to reveal 20 additional entries at a time. Changing the query resets the expanded sections. Single-section results keep a full hard-capped list.
When a typed query hits **both** open tabs and worktrees, the palette interleaves a short preview of each section so neither primary list is buried. If a section has more matches, click **See more** in its _N more_ row to reveal 20 additional entries at a time. Changing the query resets the expanded sections. Single-section results keep a full hard-capped list.
Shift-Enter on a worktree opens it in a new split instead of swapping the current pane.
When the query does not match an existing worktree, the palette offers a **Create worktree** row using the typed text as the name. Existing matches stay selected first, so pressing Enter still jumps when a real result is available.
<Callout>
Shortcut bindings are remappable under [Settings → Shortcuts](/docs/settings).
</Callout>
<Callout>Shortcut bindings are remappable under [Settings → Shortcuts](/docs/settings).</Callout>
@@ -3,7 +3,7 @@ title: Session restore
description: Quit Orca, reopen it, and pick up exactly where you left off — worktrees, splits, scrollback, focused tab.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
When you close Orca, the next launch rehydrates the whole workspace: every open worktree, every terminal split, the scrollback in each pane, and the tab you had focused. You shouldn't have to remember which agents you had running on which branches — that's Orca's job.
@@ -26,15 +26,18 @@ A daemon crash while Orca is closed has the same effect for any sessions it was
Session restore runs on every launch. The cases divide by whether the **daemon** survived the gap or not:
**Daemon survives → agents keep running:**
- **Cmd-Q** — the normal way to quit. Agents keep working in the background.
- **Auto-updater relaunch** — Orca restarts to install an update; agents are unaffected.
- **App crash** — if Orca itself crashes, the daemon keeps your sessions alive for warm reattach on next launch.
**Daemon dies → agents are gone, layout still restores:**
- **Host reboot** — laptop restart, OS update, kernel panic, hard power-off. Worktrees, tabs, splits, and the last-persisted scrollback still come back on next launch.
<Callout title="Starting fresh">
If you want a clean slate, close worktrees explicitly before quitting. There is no "open in a new session" mode — Orca always restores. Closed worktrees stay closed.
If you want a clean slate, close worktrees explicitly before quitting. There is no "open in a new
session" mode — Orca always restores. Closed worktrees stay closed.
</Callout>
## Next steps
@@ -3,11 +3,14 @@ title: Tabs, panes & split layouts
description: Drag-to-split panes, tab groups, and pinned boundaries.
---
import { ImagePlaceholder } from '@/components/docs/prose';
import { ImagePlaceholder } from '@/components/docs/prose'
Orca's pane system is designed for watching multiple agents work without losing context. Tabs group into panes; panes split into layouts.
<ImagePlaceholder src="/docs/tab-split.gif" caption="Drag a tab to a pane edge to split — terminals, diffs, and browser tabs side by side" />
<ImagePlaceholder
src="/docs/tab-split.gif"
caption="Drag a tab to a pane edge to split — terminals, diffs, and browser tabs side by side"
/>
## Tabs
@@ -22,11 +25,11 @@ Each tab holds one thing: a terminal, an editor buffer, a browser, a diff, a PR.
Default chords on **new installs**:
| Action | macOS | Linux / Windows |
| --- | --- | --- |
| Next / previous tab (all types) | `Cmd+Shift+]` / `Cmd+Shift+[` | `Ctrl+Shift+]` / `Ctrl+Shift+[` |
| Next / previous tab (same type) | `Cmd+Option+]` / `Cmd+Option+[` | `Ctrl+Alt+]` / `Ctrl+Alt+[` |
| Previous recent tab | `Ctrl+Tab` | `Ctrl+Tab` |
| Action | macOS | Linux / Windows |
| ------------------------------- | ------------------------------- | ------------------------------- |
| Next / previous tab (all types) | `Cmd+Shift+]` / `Cmd+Shift+[` | `Ctrl+Shift+]` / `Ctrl+Shift+[` |
| Next / previous tab (same type) | `Cmd+Option+]` / `Cmd+Option+[` | `Ctrl+Alt+]` / `Ctrl+Alt+[` |
| Previous recent tab | `Ctrl+Tab` | `Ctrl+Tab` |
Remap under [Settings → Shortcuts](/docs/settings). Existing installs keep customized overrides in `~/.orca/keybindings.json`.
+4 -3
View File
@@ -3,7 +3,7 @@ title: Worktrees
description: How Orca turns every feature or bug into its own git worktree.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca is worktree-native. Instead of branching and stashing on one checkout, every task gets its own on-disk copy of the repo via `git worktree`. This is what makes parallel agents safe — they never step on each other's files.
@@ -126,7 +126,7 @@ Bulk-deleting workspaces (sidebar multi-select or Resource Manager cleanup) stil
When you import a parent folder that contains several Git repos, Orca can group those repos under a single **project group** in the sidebar. Each project group exposes a **folder workspace** flow — a worktree-like entry that lives at the parent-folder level and binds its task source to one of the repos underneath, so one feature's GitHub/GitLab/Linear/Jira task surface stays attached to the right repo even though the workspace itself is grouped with its siblings in the sidebar.
To create one, hover the project group's header row in the sidebar and click the **+** action (tooltip: "Create workspace for *group*"). The composer dialog ("Create Folder Workspace") asks you to pick the source project for the workspace's task source, name the workspace, and optionally attach a linked issue or PR. Submit, and the folder workspace appears under the project group alongside the regular repo-scoped worktrees.
To create one, hover the project group's header row in the sidebar and click the **+** action (tooltip: "Create workspace for _group_"). The composer dialog ("Create Folder Workspace") asks you to pick the source project for the workspace's task source, name the workspace, and optionally attach a linked issue or PR. Submit, and the folder workspace appears under the project group alongside the regular repo-scoped worktrees.
When deleting a project group, Orca also offers a checkbox to remove the group's contained projects (the underlying repo registrations) in the same action — so cleanup of a no-longer-used cluster is one confirmation, not several.
@@ -139,5 +139,6 @@ Worktrees you create yourself with `git worktree add` stay external until you sh
In **Settings → General → Workspace**, set global defaults for external-worktree sources: Claude Code worktrees, GSD worktrees, other locations, and any custom absolute-path locations you add. Those defaults apply to current and future worktrees on that host; new global custom roots start hidden. In a project's **Non-Orca worktrees** dialog, you can override a source for that project or return it to the global setting, then search and recover individual hidden worktrees.
<Callout>
If you `git worktree remove` from the CLI, Orca will notice and clean up its own state the next time it refreshes that repo.
If you `git worktree remove` from the CLI, Orca will notice and clean up its own state the next
time it refreshes that repo.
</Callout>
@@ -2,7 +2,7 @@
title: Work on a remote machine over SSH
---
Point Orca at any SSH target — a beefier dev box, a GPU host, a cloud sandbox — and it feels like a local worktree. Same editor, same diff view, same agents, different compute. You can open remote repos *or* just arbitrary folders. For the full menu of local / SSH / server / ephemeral-VM modes, see [Ways to run Orca](/docs/ways-to-run).
Point Orca at any SSH target — a beefier dev box, a GPU host, a cloud sandbox — and it feels like a local worktree. Same editor, same diff view, same agents, different compute. You can open remote repos _or_ just arbitrary folders. For the full menu of local / SSH / server / ephemeral-VM modes, see [Ways to run Orca](/docs/ways-to-run).
## Setup
+17 -10
View File
@@ -3,14 +3,15 @@ title: Remote Orca Servers
description: Keep Orca running on another computer and connect from your laptop.
---
import { Callout, ImagePlaceholder } from '@/components/docs/prose';
import { Callout, ImagePlaceholder } from '@/components/docs/prose'
A Remote Orca Server lets one computer do the work while another computer provides the UI. The server keeps the projects, worktrees, terminals, tabs, provider accounts, and agent sessions. Your laptop connects to that running Orca instance.
The easiest setup is the Orca desktop app on both computers, connected through [Tailscale](https://tailscale.com/). You do not need to run `orca serve` for this path.
<Callout title="Beta">
Remote Orca Servers are beta. Keep the server and client on a private network path you control, such as the same Tailscale tailnet or LAN.
Remote Orca Servers are beta. Keep the server and client on a private network path you control,
such as the same Tailscale tailnet or LAN.
</Callout>
## What runs where
@@ -68,7 +69,8 @@ On the computer that should keep the sessions running:
If the Tailscale address is missing, confirm Tailscale is connected and click the refresh button beside **Connection address**.
<Callout title="Keep the access link private">
The pairing URL grants access to this Orca runtime. Treat it like a password and send it only to the client you intend to pair.
The pairing URL grants access to this Orca runtime. Treat it like a password and send it only to
the client you intend to pair.
</Callout>
### 2. Add the server on your laptop
@@ -87,6 +89,11 @@ On the computer you want to use as the client:
caption="On the client: name the server, paste its access link, and add it. The pairing code is redacted in this example."
/>
<ImagePlaceholder
src="/docs/remote-server-share-desktop.png"
caption="Share an Orca desktop runtime with another client over a private network."
/>
Adding a server saves it without forcing every new project onto it. Open **Advanced → Active Server** only when you want server-routed projects, terminals, provider checks, and browser or mobile handoff to use that server by default.
## Use the remote server
@@ -158,13 +165,13 @@ Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan
## Desktop app or `orca serve`?
| | Desktop app on the server | `orca serve` |
| --- | --- | --- |
| Best for | An old laptop, Mac mini, or desktop | A headless Linux box, VM, or managed service |
| Setup | Settings and buttons | Terminal command and service configuration |
| Server window | Open | None |
| Access link | **New Link → Generate Access Link** | Printed in the terminal |
| Lifetime | While the desktop app is running | While the foreground process or service is running |
| | Desktop app on the server | `orca serve` |
| ------------- | ----------------------------------- | -------------------------------------------------- |
| Best for | An old laptop, Mac mini, or desktop | A headless Linux box, VM, or managed service |
| Setup | Settings and buttons | Terminal command and service configuration |
| Server window | Open | None |
| Access link | **New Link → Generate Access Link** | Printed in the terminal |
| Lifetime | While the desktop app is running | While the foreground process or service is running |
## Remote Orca Server or SSH?
@@ -2,8 +2,12 @@
title: Annotate AI Diff
---
import { ImagePlaceholder } from '@/components/docs/prose';
Annotate AI Diff is Orca's inline review loop for agent-generated code. You leave comments on any line of any AI-generated hunk, then send them back to the agent as a single batch for revision — no copying line numbers, no context-switching.
<ImagePlaceholder src="/docs/annotate-ai-diff.gif" caption="Annotate a generated diff and send one batch of line-level notes back to the agent" />
## Leave a comment
1. Hover any line in the diff. A **+** appears in the gutter.
+7 -3
View File
@@ -3,7 +3,7 @@ title: Jira items drawer
description: Browse, edit, and link Jira Cloud or self-hosted Server/Data Center issues to worktrees the same way you link Linear or GitHub items.
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Jira sits next to GitHub and Linear in the task drawer. Browse Jira issues, update them, and create a worktree from any issue without leaving Orca.
@@ -27,7 +27,9 @@ Jira sits next to GitHub and Linear in the task drawer. Browse Jira issues, upda
- **Username and password** — Basic auth for older instances without PATs.
<Callout title="Use HTTPS">
Use an `https://` URL for Cloud and self-hosted Jira whenever possible. Orca sends credentials in the `Authorization` header; plain HTTP can expose them on the network. If a server only offers HTTP, put it behind a trusted HTTPS tunnel or VPN before connecting.
Use an `https://` URL for Cloud and self-hosted Jira whenever possible. Orca sends credentials in
the `Authorization` header; plain HTTP can expose them on the network. If a server only offers
HTTP, put it behind a trusted HTTPS tunnel or VPN before connecting.
</Callout>
4. Click **Connect**. Orca verifies the credentials and loads your sites.
@@ -47,7 +49,9 @@ If you don't use Jira at all, hide it from the source picker via [Settings → T
- Orca remembers your last-used task source per repo, so a Jira-driven repo defaults to Jira on next open.
<Callout title="Where credentials live">
Your Atlassian API token or self-hosted credentials are encrypted via the OS keychain and stored locally — they're only used to call your configured Jira site. Revoke tokens from Atlassian account settings if you stop using Orca.
Your Atlassian API token or self-hosted credentials are encrypted via the OS keychain and stored
locally — they're only used to call your configured Jira site. Revoke tokens from Atlassian
account settings if you stop using Orca.
</Callout>
## Next steps
+3 -2
View File
@@ -2,7 +2,7 @@
title: Linear items drawer
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Linear sits next to hosted review providers in the task drawer. Browse, create, update, and link Linear issues to worktrees the same way you link GitHub issues.
@@ -26,7 +26,8 @@ Linear sits next to hosted review providers in the task drawer. Browse, create,
- Orca remembers your last-used task source (GitHub, Linear, or Jira) per repo.
<Callout>
Linear status sync (moving an issue to "In Progress" when a worktree is created) is opt-in per team.
Linear status sync (moving an issue to "In Progress" when a worktree is created) is opt-in per
team.
</Callout>
## Agents and CLI
+5 -5
View File
@@ -9,11 +9,11 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
- **Orca CLI** — register the bundled command-line tool for shells and agents.
- **Updates** — check for and install updates. Modifier clicks on **Check for Updates**:
| Modifier | Effect |
| --- | --- |
| **Shift+click** | Include the latest **RC** prerelease |
| **Cmd+click** (macOS) / **Ctrl+click** (Windows/Linux) | Latest **perf**-tagged prerelease |
| **Option+click** (macOS only) | Pick a **validated local macOS build** (compatibility-checked). Failures surface as “Could Not Use Local Build” with **Choose Another Build**. |
| Modifier | Effect |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Shift+click** | Include the latest **RC** prerelease |
| **Cmd+click** (macOS) / **Ctrl+click** (Windows/Linux) | Latest **perf**-tagged prerelease |
| **Option+click** (macOS only) | Pick a **validated local macOS build** (compatibility-checked). Failures surface as “Could Not Use Local Build” with **Choose Another Build**. |
- **Open in menu** — choose apps on the worktree **Open in** menu. VS Code / Insiders enable **Remote SSH** open for SSH worktrees; other editors remain local-path only.
- **UI zoom** — per-install UI scale.
+3 -2
View File
@@ -2,12 +2,13 @@
title: SSH worktrees
---
import { Callout } from '@/components/docs/prose';
import { Callout } from '@/components/docs/prose'
Orca can drive agents on remote machines over SSH — useful for long-running builds, GPU boxes, or any environment where your laptop isn't the right place to run the work.
<Callout title="One of four run modes">
SSH is one way to put agents on remote compute. For local, self-hosted servers, and ephemeral VMs, see [Ways to run Orca](/docs/ways-to-run).
SSH is one way to put agents on remote compute. For local, self-hosted servers, and ephemeral VMs,
see [Ways to run Orca](/docs/ways-to-run).
</Callout>
## Add an SSH target
+10 -5
View File
@@ -3,7 +3,7 @@ title: Privacy & Telemetry
description: What anonymous usage data Orca collects, what it never collects, and how to opt out.
---
import { ImagePlaceholder } from '@/components/docs/prose';
import { ImagePlaceholder } from '@/components/docs/prose'
This page describes the anonymous product-usage telemetry we collect in packaged Orca builds, what we never collect, and how to opt out.
@@ -20,7 +20,7 @@ Alongside each event we include basic build and platform information: the Orca v
The categories of behavior we observe:
- **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users.
- **Repos and workspaces** — when you add a repo or create a workspace. We record *how* you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text.
- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text.
- **Agents** — when you start an agent, which agent kind it was (from a fixed list like `claude-code`, `codex`, `gemini`, etc.), and where you launched it from. Never the prompt, model details, or agent output.
- **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle.
- **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting.
@@ -43,9 +43,14 @@ You can disable telemetry in three ways. Any one of them is sufficient; they com
1. **In the app.** Settings → Privacy → toggle "Share anonymous usage data" off. The change is immediate and persistent.
<ImagePlaceholder src="/docs/privacy-toggle.png" caption="Settings → Privacy — toggle 'Share anonymous usage data' off to disable telemetry." />
2. **`DO_NOT_TRACK=1`** — community-standard environment variable. Disables transmission for that launch. Unsetting it restores your stored preference on the next launch.
3. **`ORCA_TELEMETRY_DISABLED=1`** — Orca-specific kill switch with the same semantics as `DO_NOT_TRACK`.
<ImagePlaceholder
src="/docs/privacy-toggle.png"
caption="Settings → Privacy — toggle 'Share anonymous usage data' off to disable telemetry."
/>
2. **`DO_NOT_TRACK=1`** — community-standard environment variable. Disables transmission for that
launch. Unsetting it restores your stored preference on the next launch. 3.
**`ORCA_TELEMETRY_DISABLED=1`** — Orca-specific kill switch with the same semantics as
`DO_NOT_TRACK`.
## Where the data goes
+1 -1
View File
@@ -81,4 +81,4 @@ Quick Commands save terminal commands you run often, such as `npm run dev`, `pnp
Each command has a label, command text, and scope. Use **Global** for commands that apply everywhere, or **Project** to show the command only in worktrees for a specific repo. The tab-bar button opens a fresh terminal tab and runs the command; the terminal context menu can insert commands into the current terminal. Use the copy control on a command row (Settings list, tab-bar menu, or [mobile Quick Commands](/docs/mobile#quick-commands)) to put the command body on the clipboard.
When you work with a paired [Remote Orca Server](/docs/remote-servers) (or another execution host), the picker can show **local and remote** collections side by side, labeled by host (for example *Local Mac* and *Orca Server*). **Saved on** is where the command is stored; running a command still executes in the terminal or workspace where you invoke it — so a client-owned command can run inside a remote worktree. Older servers that do not advertise multi-host Quick Commands fall back to the local-only list.
When you work with a paired [Remote Orca Server](/docs/remote-servers) (or another execution host), the picker can show **local and remote** collections side by side, labeled by host (for example _Local Mac_ and _Orca Server_). **Saved on** is where the command is stored; running a command still executes in the terminal or workspace where you invoke it — so a client-owned command can run inside a remote worktree. Older servers that do not advertise multi-host Quick Commands fall back to the local-only list.
+21 -14
View File
@@ -3,7 +3,7 @@ title: Ways to run Orca
description: Local desktop, SSH hosts, self-hosted Orca servers, and on-demand per-workspace VMs — pick the right compute for each task.
---
import { Callout, ImagePlaceholder } from '@/components/docs/prose';
import { Callout, ImagePlaceholder } from '@/components/docs/prose'
Orca is not locked to your laptop. Every worktree runs somewhere — on the machine in front of you, on a box you already own, on a shared always-on server, or on a fresh cloud VM spun up for that one workspace.
@@ -11,12 +11,12 @@ This page is the map. Deep dives live on the linked pages.
## At a glance
| Mode | Where files and agents live | Who owns the machine | Best for |
| --- | --- | --- | --- |
| **Local** | Your desktop | You | Day-to-day coding, fast iteration |
| **SSH target** | A remote host you connect to over SSH | You (or your team) | Dev boxes, GPU hosts, always-on VPS |
| **Remote Orca Server** | A machine running Orca desktop or `orca serve` | You (or your team) | Persistent shared runtime, mobile, automation |
| **Cloud VM / per-workspace environment** | A disposable VM/sandbox per workspace | Your cloud account (BYO provider) | Isolated, ephemeral agent compute |
| Mode | Where files and agents live | Who owns the machine | Best for |
| ---------------------------------------- | ---------------------------------------------- | --------------------------------- | --------------------------------------------- |
| **Local** | Your desktop | You | Day-to-day coding, fast iteration |
| **SSH target** | A remote host you connect to over SSH | You (or your team) | Dev boxes, GPU hosts, always-on VPS |
| **Remote Orca Server** | A machine running Orca desktop or `orca serve` | You (or your team) | Persistent shared runtime, mobile, automation |
| **Cloud VM / per-workspace environment** | A disposable VM/sandbox per workspace | Your cloud account (BYO provider) | Isolated, ephemeral agent compute |
Orca does **not** sell managed VPS hosting. Remote modes always use machines and cloud accounts you control.
@@ -62,12 +62,12 @@ Full detail: [Remote Orca Servers](/docs/remote-servers).
### SSH vs Remote Orca Server
| | SSH worktrees | Remote Orca Server |
| --- | --- | --- |
| Runtime owner | Laptop Orca | Remote machine (Orca desktop or `orca serve`) |
| Disconnect | Agents keep running on the host; laptop reattaches | Full session state lives on the server |
| Multi-client | One laptop drives the host | Laptop, web, mobile, and automation can share the same runtime |
| Typical setup | Import SSH config, pick **Run on** | Share the server app or run `orca serve`, then pair with a URL |
| | SSH worktrees | Remote Orca Server |
| ------------- | -------------------------------------------------- | -------------------------------------------------------------- |
| Runtime owner | Laptop Orca | Remote machine (Orca desktop or `orca serve`) |
| Disconnect | Agents keep running on the host; laptop reattaches | Full session state lives on the server |
| Multi-client | One laptop drives the host | Laptop, web, mobile, and automation can share the same runtime |
| Typical setup | Import SSH config, pick **Run on** | Share the server app or run `orca serve`, then pair with a URL |
## 4. Cloud VMs (per-workspace environments)
@@ -82,6 +82,11 @@ Providers people wire today include Vercel Sandbox, Fly, Modal, plain SSH hosts,
caption="Settings → Experimental → Cloud VM — enable the skill, then have an agent set up a recipe for the repo."
/>
<ImagePlaceholder
src="/docs/ways-to-run-local-sidebar.png"
caption="Choose a local or remote run target from the workspace sidebar."
/>
**Good fit when:** you want clean isolation per task, disposable compute, or a standard environment every agent boots into.
**Setup sketch:**
@@ -100,7 +105,9 @@ Providers people wire today include Vercel Sandbox, Fly, Modal, plain SSH hosts,
Recipes only show up for workspace create once the `environmentRecipes` entry is on the project's **primary** checkout of `orca.yaml` (not only a feature branch). Doctor and live provision can still run from any branch while you iterate on scripts.
<Callout title="BYO cloud — not an Orca VPS">
Cloud VMs do not give you an Orca-hosted VPS. You bring the provider (and pay that provider). Orca runs your create/suspend/resume/destroy scripts and connects over the pairing URL or SSH details they print.
Cloud VMs do not give you an Orca-hosted VPS. You bring the provider (and pay that provider). Orca
runs your create/suspend/resume/destroy scripts and connects over the pairing URL or SSH details
they print.
</Callout>
## How to choose
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from 'next'
import { source } from '@/lib/source'
const siteUrl = 'https://www.onorca.dev'
export default function sitemap(): MetadataRoute.Sitemap {
return source.getPages().map((page) => ({
url: `${siteUrl}${page.url}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: page.url === '/docs' ? 0.9 : 0.7
}))
}
+3 -1
View File
@@ -44,7 +44,9 @@ export default function RootLayout({
return (
<html lang="en" className="bg-background text-foreground" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning>
<RootProvider search={{ enabled: false }}>{children}</RootProvider>
<RootProvider search={{ enabled: false }} theme={{ defaultTheme: 'dark' }}>
{children}
</RootProvider>
</body>
</html>
)
@@ -62,6 +62,16 @@ export function DocsFooter() {
Discord
</a>
</li>
<li>
<a
href="https://x.com/orca_build"
target="_blank"
rel="noopener noreferrer"
className="rounded-md px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
X
</a>
</li>
</ul>
</div>
</div>
+35 -5
View File
@@ -1,9 +1,32 @@
import Link from 'next/link'
import Image from 'next/image'
import { GitBranch, MessageCircle, X } from 'lucide-react'
import { MessageCircle, Star } from 'lucide-react'
import { ThemeSwitch } from 'fumadocs-ui/layouts/shared/slots/theme-switch'
export function DocsHeader() {
async function getGithubStars(): Promise<number | undefined> {
try {
const response = await fetch('https://api.github.com/repos/stablyai/orca', {
headers: { Accept: 'application/vnd.github+json' },
next: { revalidate: 3600 }
})
if (!response.ok) {
return undefined
}
const payload = (await response.json()) as { stargazers_count?: unknown }
return typeof payload.stargazers_count === 'number' ? payload.stargazers_count : undefined
} catch {
return undefined
}
}
function formatStars(stars: number): string {
return stars >= 1000 ? `${(stars / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(stars)
}
export async function DocsHeader() {
const stars = await getGithubStars()
const formattedStars = stars === undefined ? undefined : formatStars(stars)
return (
<header className="fixed inset-x-0 top-0 z-30 border-b border-border bg-background/95 backdrop-blur-xl supports-[backdrop-filter]:bg-background/85">
<div className="container mx-auto flex h-14 max-w-[1200px] items-center justify-between gap-4 px-4">
@@ -61,7 +84,9 @@ export function DocsHeader() {
className="hidden size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 sm:flex"
aria-label="Follow Orca on X"
>
<X className="size-4" aria-hidden="true" />
<span aria-hidden="true" className="text-[15px] font-semibold leading-none">
𝕏
</span>
</a>
<a
href="https://github.com/stablyai/orca"
@@ -69,8 +94,13 @@ export function DocsHeader() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<GitBranch className="size-4" aria-hidden="true" />
GitHub
<Star className="size-3.5 fill-current" aria-hidden="true" />
<span>Star</span>
{formattedStars && (
<span className="rounded-full bg-accent px-1.5 py-0.5 text-[11px] font-semibold text-foreground">
{formattedStars}
</span>
)}
</a>
</div>
</div>
@@ -3,6 +3,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createMobileNativeChatStreamingGate,
deriveMobileNativeChatStreaming,
mobileNativeChatStreamPreview,
type MobileNativeChatStreamingGate
} from './mobile-native-chat-streaming-gate'
@@ -33,6 +34,40 @@ function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean
return { gate, results }
}
describe('mobileNativeChatStreamPreview', () => {
it('drops a preview the provider flagged as tool output', () => {
// Regression: a Bash result was published as `lastAssistantMessage` for the status
// card, then rendered here as an un-collapsed assistant bubble that no catch-up rule
// could retire, so it sat in the chat for the rest of the turn.
expect(
mobileNativeChatStreamPreview(
{
lastAssistantMessage: 'Exit code 1\nimport { Foo }',
lastAssistantMessageIsToolOutput: true
},
true
)
).toBeUndefined()
})
it('passes assistant prose through while working', () => {
expect(mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, true)).toBe(
'Working on it'
)
})
it('drops any preview once the turn is not working', () => {
expect(
mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, false)
).toBeUndefined()
})
it('tolerates a missing status', () => {
expect(mobileNativeChatStreamPreview(null, true)).toBeUndefined()
expect(mobileNativeChatStreamPreview(undefined, true)).toBeUndefined()
})
})
describe('deriveMobileNativeChatStreaming', () => {
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
const prior = [assistant('a1', 'The tests pass.')]
@@ -18,6 +18,27 @@ export type MobileNativeChatStreamingGate = {
baselineTailId: string | null
}
/** The preview text to feed the gate for one tick, or undefined for "no observation".
*
* Providers publish a tool's stdout/error as `lastAssistantMessage` so status cards can
* preview it. That text is not the reply and never lands in a transcript assistant block,
* so the catch-up rule below could never retire it — it stayed on screen as a wall of raw
* tool output until the turn ended. Treating it as no observation (rather than empty text
* that still anchors) also keeps it out of `prevText`, so the next real reply is not
* mistaken for a continuation of a tool result. */
export function mobileNativeChatStreamPreview(
status:
| { lastAssistantMessage?: string; lastAssistantMessageIsToolOutput?: boolean }
| null
| undefined,
working: boolean
): string | undefined {
if (!working || status?.lastAssistantMessageIsToolOutput === true) {
return undefined
}
return status?.lastAssistantMessage
}
export function createMobileNativeChatStreamingGate(
scopeKey: string | null = null
): MobileNativeChatStreamingGate {
@@ -12,6 +12,7 @@ import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
@@ -127,7 +128,7 @@ export function useMobileNativeChatController(args: {
// 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,
mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
@@ -28,7 +28,7 @@ function fakeClient(script: (method: string, call: number) => unknown, calls: Ca
}
describe('createBlankWorkspace', () => {
it('sends no agent-launch fields for a blank workspace', async () => {
it('pins a manually entered blank-workspace name and sends no agent-launch fields', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
@@ -51,6 +51,8 @@ describe('createBlankWorkspace', () => {
repo: 'id:repo-1',
setupDecision: 'inherit',
name: 'octopus',
displayName: 'octopus',
displayNameKind: 'user',
// Idempotency key so a create interrupted by a connection migration can be
// safely retried without the host spawning a duplicate worktree.
clientMutationId: expect.any(String)
@@ -32,6 +32,9 @@ export async function createBlankWorkspace(args: {
repo: `id:${args.repoId}`,
setupDecision: args.setupDecision,
name,
...(args.nameWasGenerated
? { displayNameKind: 'generated' as const }
: { displayName: args.baseName, displayNameKind: 'user' as const }),
...(args.nameWasGenerated ? { nameWasGenerated: true } : {}),
...agentLaunchCreateFields(args.createdWithAgentId)
}
@@ -16,11 +16,11 @@ const hash = (parts: string[] | string): string =>
.update(Array.isArray(parts) ? parts.join('\n') : parts)
.digest('hex')
const PRE_REFACTOR_SCREEN_HOOKS = '1d8e1b69c4ac80e5e035cc2ece62a88a73bb35b0b60af74f3e75e54db5df3f75'
const PRE_REFACTOR_SCREEN_HOOKS = '42174315a76c475d09dcb7209af4481f01258c4c9dc012127ff07a893d8cd291'
const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
const PRE_REFACTOR_STATEMENTS = 'ef0e5bd607a96ff11fb60cff285bc01dfcfeb804cd29a896cca107b09994b610'
const PRE_REFACTOR_STATEMENTS = '9323fbee7c3806f37de42578ba73ce659c786c0ed5f8b6bcbc321b201ca50a73'
const PRE_REFACTOR_DECLARATIONS = 'cff54172af17a877789be1479c2eb6ca97d83c3e31dd831cd59395962f2b4c4a'
const PRE_REFACTOR_SEMANTICS = '60be4eee5513751530f98925828dc59e9775838842dafb5e5c7e9f25bf8a016d'
const PRE_REFACTOR_SEMANTICS = '5219d210d6f274e9ce2716a37c4c6fc4860a736a80f059ab6e89da6123043263'
const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f'
@@ -49,7 +49,7 @@ describe('Mobile Tasks refactor parity', () => {
it('preserves RPC calls, runtime strings, and JSX host signatures', () => {
const semantics = readMobileTasksSemanticSource()
expect(semantics.split('\n')).toHaveLength(3_498)
expect(semantics.split('\n')).toHaveLength(3_499)
expect(hash(semantics)).toBe(PRE_REFACTOR_SEMANTICS)
})
@@ -174,7 +174,62 @@ describe('createWorkspaceFromComposerSource', () => {
})
})
it('suppresses displayName when the name is user-edited (not auto-managed)', async () => {
it('does not pin an automatically managed branch selection without a custom label', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'branch',
baseBranch: 'main',
refName: 'main',
localBranchName: 'topic',
reuse: false,
branchNameOverride: 'topic'
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls[0]!.params).not.toHaveProperty('displayName')
expect(calls[0]!.params).not.toHaveProperty('displayNameKind')
})
it('does not pin an auto-derived branch label even when the draft is populated', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch-draft' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'new-branch',
branchName: 'topic'
}
await createWorkspaceFromComposerSource({
client,
selection,
...baseArgs,
workspaceName: 'topic',
nameIsAutoManaged: true
})
expect(calls[0]!.params).not.toHaveProperty('displayName')
expect(calls[0]!.params).not.toHaveProperty('displayNameKind')
})
it('pins a custom label for a new branch selection', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-labeled-branch' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'new-branch',
branchName: 'feature/login'
}
await createWorkspaceFromComposerSource({
client,
selection,
...baseArgs,
workspaceName: ' Login work '
})
expect(calls[0]!.params).toMatchObject({
displayName: 'Login work',
displayNameKind: 'user'
})
})
it('pins displayName when the name is user-edited (not auto-managed)', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls)
const selection: MobileComposerCreateSelection = {
@@ -195,8 +250,12 @@ describe('createWorkspaceFromComposerSource', () => {
workspaceName: 'my-name',
nameIsAutoManaged: false
})
expect(calls[0]!.params.displayName).toBeUndefined()
expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 })
expect(calls[0]!.params).toMatchObject({
name: 'my-name',
displayName: 'my-name',
displayNameKind: 'user',
linkedIssue: 7
})
})
it('creates a new branch off a ref, bumping the branch on collision', async () => {
+36 -5
View File
@@ -28,8 +28,8 @@ export type CreateWorkspaceFromComposerArgs = {
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}
@@ -91,8 +91,8 @@ async function createWorkItemWorkspace(args: {
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
@@ -150,12 +150,23 @@ async function createBranchWorkspace(args: {
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const {
client,
selection,
targetRepoId,
setupDecision,
agent,
workspaceName,
nameIsAutoManaged,
note
} = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim()
const applyCommon = (params: Record<string, unknown>): Record<string, unknown> => {
Object.assign(params, agentLaunchCreateFields(createdWithAgentId))
if (comment) {
@@ -181,6 +192,9 @@ async function createBranchWorkspace(args: {
applyCommon({
repo: `id:${targetRepoId}`,
name,
...(manualDisplayName
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
: {}),
setupDecision,
baseBranch: selection.refName,
branchNameOverride: selection.localBranchName
@@ -203,7 +217,10 @@ async function createBranchWorkspace(args: {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
baseBranch: selection.baseBranch
baseBranch: selection.baseBranch,
...(manualDisplayName
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
: {})
}
if (selection.branchNameOverride) {
params.branchNameOverride = candidate
@@ -220,11 +237,22 @@ async function createNewBranchWorkspace(args: {
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const {
client,
selection,
targetRepoId,
setupDecision,
agent,
workspaceName,
nameIsAutoManaged,
note
} = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim()
const comment = note?.trim()
// A brand-new branch off the repo's default base. The typed name is kept as the
// git branch (via branchNameOverride) so a slash like `feature/login` survives;
@@ -240,6 +268,9 @@ async function createNewBranchWorkspace(args: {
name: candidate,
setupDecision,
branchNameOverride: candidate,
...(manualDisplayName
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
: {}),
...agentLaunchCreateFields(createdWithAgentId)
}
if (comment) {
@@ -39,7 +39,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
taskStateHydrated,
tasksSupported,
trustedOrcaHooks,
workspaceDetectedAgentIds
workspaceDetectedAgentIds,
workspaceLastAutoName
} = model
const createWorkspace = useCallback(
async (
@@ -149,6 +150,9 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
})
return
}
const trimmedWorkspaceName = workspaceNameOverride?.trim() ?? ''
const nameIsAutoManaged =
!trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName
let params: Record<string, unknown>
if (item.provider === 'github') {
const source = item.source
@@ -192,7 +196,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
baseBranch: baseBranchOverride,
branchNameOverride,
sparseCheckout: sparseCheckoutOverride,
hostedStartPoint: prStartPoint
hostedStartPoint: prStartPoint,
nameIsAutoManaged
})
} else if (item.provider === 'gitlab') {
const source = item.source
@@ -236,7 +241,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
baseBranch: baseBranchOverride,
branchNameOverride,
sparseCheckout: sparseCheckoutOverride,
hostedStartPoint: mrStartPoint
hostedStartPoint: mrStartPoint,
nameIsAutoManaged
})
} else {
params = buildTaskWorkspaceCreateParams({
@@ -248,7 +254,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
note: comment,
baseBranch: baseBranchOverride,
branchNameOverride,
sparseCheckout: sparseCheckoutOverride
sparseCheckout: sparseCheckoutOverride,
nameIsAutoManaged
})
}
const response = await client.sendRequest('worktree.create', params, {
@@ -289,7 +296,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
taskStateHydrated,
tasksSupported,
trustedOrcaHooks,
workspaceDetectedAgentIds
workspaceDetectedAgentIds,
workspaceLastAutoName
]
)
return Object.assign(model, { createWorkspace })
@@ -41,6 +41,7 @@ describe('task workspace create params', () => {
repo: 'id:repo-1',
name: 'mobile-tasks',
displayName: 'Fix mobile tasks',
displayNameKind: 'generated',
setupDecision: 'run',
activate: true,
startupDraft: 'https://github.com/acme/app/pull/123',
@@ -72,6 +73,7 @@ describe('task workspace create params', () => {
repo: 'id:repo-1',
name: 'issue-88',
displayName: 'Investigate login',
displayNameKind: 'generated',
setupDecision: 'skip',
activate: true,
linkedIssue: 88
@@ -80,6 +82,31 @@ describe('task workspace create params', () => {
expect(params).not.toHaveProperty('createdWithAgent')
})
it('marks an edited task label as user-owned', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'issue',
repoId: 'repo-1',
number: 88,
title: 'Investigate login',
url: 'https://github.com/acme/app/issues/88'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'skip',
workspaceName: 'My workspace',
nameIsAutoManaged: false
})
expect(params).toMatchObject({
name: 'My workspace',
displayName: 'My workspace',
displayNameKind: 'user'
})
})
it('keeps the startup draft when no agent was provided so the host can auto-pick', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
+6 -3
View File
@@ -108,8 +108,7 @@ export function buildTaskWorkspaceCreateParams(args: {
const comment = note?.trim()
const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch
const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget
// Why: desktop only sends displayName while the name is still auto-derived; a
// user-edited name suppresses it so the runtime keeps the user's chosen name.
// Preserve provenance so the host can distinguish an intentional label from a generated title.
const sourceName =
item.provider === 'linear'
? getWorkspaceSourceName({
@@ -121,7 +120,11 @@ export function buildTaskWorkspaceCreateParams(args: {
linearIdentifier: item.source.identifier
})
: getWorkspaceSourceName({ provider: item.provider, ...item.source })
const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {}
const displayName = nameIsAutoManaged
? { displayName: sourceName.displayName, displayNameKind: 'generated' as const }
: workspaceName?.trim()
? { displayName: workspaceName, displayNameKind: 'user' as const }
: {}
const common = {
setupDecision,
activate: true,
+41 -2
View File
@@ -55,7 +55,7 @@ async function flush(): Promise<void> {
// cutover). Records every call so tests can assert on the clientMutationId.
function scriptedClient(
outcomes: Array<
| { id: string }
| { id: string; displayName?: string }
| { errorMessage: string }
// takesMs models how long the ambiguity took to SURFACE — a clean close is
// instant, a half-open socket waits out the liveness watchdog or the timeout.
@@ -103,7 +103,12 @@ function scriptedClient(
return {
id: '1',
ok: true,
result: { worktree: { id: outcome.id } },
result: {
worktree: {
id: outcome.id,
...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {})
}
},
_meta: { runtimeId: 'r' }
}
}
@@ -227,6 +232,40 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts[1]!.params.name).toBe('topic-2')
})
it('uses the host-selected display name after a collision retry', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
[{ errorMessage: 'already exists locally' }, { id: 'wt-host-name', displayName: 'topic-3' }],
attempts
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: false
})
).resolves.toEqual({ worktreeId: 'wt-host-name', name: 'topic-3' })
})
it('falls back to the client candidate when an older host omits displayName', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
[{ errorMessage: 'already exists locally' }, { id: 'wt-legacy' }],
attempts
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: false
})
).resolves.toEqual({ worktreeId: 'wt-legacy', name: 'topic-2' })
})
it('advances generated retries without nesting suffixes', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
+11 -2
View File
@@ -82,8 +82,17 @@ export async function createWorktreeWithNameRetry(
: candidateParams
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
return { worktreeId: result.worktree.id, name: candidateName }
const result = (response as RpcSuccess).result as {
worktree: { id: string; displayName?: string }
}
const authoritativeName = result.worktree.displayName
return {
worktreeId: result.worktree.id,
name:
typeof authoritativeName === 'string' && authoritativeName.trim()
? authoritativeName
: candidateName
}
}
lastError = response.error.message
if (!isRetryableWorktreeCreateConflict(lastError ?? '')) {
+4 -1
View File
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -29,6 +29,7 @@
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
@@ -85,6 +86,8 @@
"build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs",
"reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs",
"rebuild:node": "pnpm rebuild node-pty",
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
+20 -20
View File
@@ -4,18 +4,18 @@
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"releaseRevision": 8,
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
"releaseRevision": 9,
"packageDigest": "ddc9f910985ae67ab693263026d99c68dc34b6f0c12b444b620cd0e19c3df36a",
"gitTreeSha": "f0561c41d1f709a953684aef5d5368f8c58d269f",
"files": [
{
"path": "SKILL.md",
"size": 3667,
"size": 3465,
"executable": false,
"classification": "text",
"exactSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
"textNormalizedSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
"exactSha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6",
"textNormalizedSha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6",
"identitySha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6"
}
]
},
@@ -41,17 +41,17 @@
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"packageDigest": "d1b830256e3fda11408320631722e07bb4bbadf99d19c94f1e00f73b7bc8462d",
"gitTreeSha": "cdf89459f89dddf347ee2759ff884c369051f06a",
"files": [
{
"path": "SKILL.md",
"size": 3944,
"size": 4150,
"executable": false,
"classification": "text",
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
"exactSha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5",
"textNormalizedSha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5",
"identitySha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5"
}
]
},
@@ -130,18 +130,18 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"releaseRevision": 28,
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
"releaseRevision": 29,
"packageDigest": "7a386ce558ba54abe02b4a0de5d71fe3d63c944ef0888ddde130c729b37f7cc8",
"gitTreeSha": "4199ec6988801dd491631706cba62631b4bed8fb",
"files": [
{
"path": "SKILL.md",
"size": 4220,
"size": 4451,
"executable": false,
"classification": "text",
"exactSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
"textNormalizedSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
"exactSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"textNormalizedSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"identitySha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f"
}
]
}
+38 -6
View File
@@ -580,17 +580,17 @@
},
{
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"packageDigest": "d1b830256e3fda11408320631722e07bb4bbadf99d19c94f1e00f73b7bc8462d",
"gitTreeSha": "cdf89459f89dddf347ee2759ff884c369051f06a",
"files": [
{
"path": "SKILL.md",
"size": 3944,
"size": 4150,
"executable": false,
"classification": "text",
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
"exactSha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5",
"textNormalizedSha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5",
"identitySha256": "6dcbd69045c74787be3385198750c67223709c5fa63a2ba3cfbbe0403e1219f5"
}
]
}
@@ -1043,6 +1043,22 @@
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
}
]
},
{
"releaseRevision": 29,
"packageDigest": "7a386ce558ba54abe02b4a0de5d71fe3d63c944ef0888ddde130c729b37f7cc8",
"gitTreeSha": "4199ec6988801dd491631706cba62631b4bed8fb",
"files": [
{
"path": "SKILL.md",
"size": 4451,
"executable": false,
"classification": "text",
"exactSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"textNormalizedSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"identitySha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f"
}
]
}
],
"mobile-fit-debug": [
@@ -1191,6 +1207,22 @@
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
}
]
},
{
"releaseRevision": 9,
"packageDigest": "ddc9f910985ae67ab693263026d99c68dc34b6f0c12b444b620cd0e19c3df36a",
"gitTreeSha": "f0561c41d1f709a953684aef5d5368f8c58d269f",
"files": [
{
"path": "SKILL.md",
"size": 3465,
"executable": false,
"classification": "text",
"exactSha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6",
"textNormalizedSha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6",
"identitySha256": "a3fcca06875e354a470e7daef5619172a3615fbbf82900ff3e0a65636fc694c6"
}
]
}
],
"orca-emulator": [

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