Merge remote-tracking branch 'origin/main' into brennanb2025/contextual-feature-tours

# Conflicts:
#	docs/reference/feature-discovery-interaction-tracking.md
#	src/main/persistence.test.ts
#	src/main/persistence.ts
#	src/main/runtime/rpc/dispatcher-feature-interactions.test.ts
#	src/main/runtime/rpc/dispatcher.ts
#	src/main/runtime/rpc/methods/client-ui.test.ts
#	src/main/runtime/rpc/methods/client-ui.ts
#	src/renderer/src/App.tsx
#	src/renderer/src/components/automations/AutomationsPage.tsx
#	src/renderer/src/components/browser-pane/BrowserPane.tsx
#	src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts
#	src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
#	src/renderer/src/components/settings/NotificationsPane.tsx
#	src/renderer/src/components/sidebar/AddRepoSetupStep.tsx
#	src/renderer/src/store/slices/browser.ts
#	src/renderer/src/store/slices/diffComments.ts
#	src/renderer/src/store/slices/tabs.ts
#	src/renderer/src/store/slices/terminals.ts
#	src/renderer/src/store/slices/ui.test.ts
#	src/renderer/src/store/slices/ui.ts
#	src/renderer/src/store/slices/workspace-space.ts
#	src/renderer/src/web/web-preload-api.test.ts
#	src/shared/constants.ts
#	src/shared/feature-interactions.test.ts
#	src/shared/feature-interactions.ts
#	src/shared/types.ts
This commit is contained in:
brennanb2025
2026-05-28 14:55:23 -07:00
795 changed files with 69862 additions and 9404 deletions
+7 -3
View File
@@ -5,10 +5,14 @@ Thanks for contributing to Orca.
## Before You Start
- Keep changes scoped to a clear user-facing improvement, bug fix, or refactor.
- Orca targets macOS, Linux, and Windows. Avoid platform-specific assumptions in shortcuts, labels, and file paths.
- Orca targets macOS, Linux, and Windows. Every change must stay compatible with all three platforms unless the code is explicitly guarded by a runtime platform check.
- For keyboard shortcuts, use runtime platform checks in renderer code and `CmdOrCtrl` in Electron menu accelerators.
- For shortcut labels, show `⌘` and `⇧` on macOS, and `Ctrl+` and `Shift+` on Linux and Windows.
- For file paths, use Node or Electron path utilities such as `path.join`.
- Orca must work against local repositories, remote servers, and SSH worktrees. Do not assume a process, file, credential, shell, or network path exists only on the local machine.
- Orca supports many CLI agents, integrations, and git providers. Keep generic behavior provider-neutral; guard integration-specific logic behind explicit checks.
- Keep changes well-engineered and performant: follow existing architecture, avoid unnecessary work in hot paths, clean up owned resources, and use concrete module names.
- For UI work, follow [`docs/STYLEGUIDE.md`](../docs/STYLEGUIDE.md), use the tokens and shadcn primitives it specifies, and verify polished behavior across platforms, light/dark mode, and SSH latency.
## Local Setup
@@ -58,8 +62,8 @@ Each pull request should:
- stay focused on a single topic when possible
- include screenshots or screen recordings for new UI or behavior changes
- include high-quality tests when behavior changes or bug fixes warrant them
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, plus a basic security audit summary
- mention any platform-specific behavior or testing notes
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
If there is no visual change, say that explicitly in the PR description.
+12 -12
View File
@@ -31,11 +31,11 @@ jobs:
os: [ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
run_install: false
- if: runner.os == 'Linux'
@@ -60,11 +60,11 @@ jobs:
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
@@ -80,11 +80,11 @@ jobs:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
@@ -105,11 +105,11 @@ jobs:
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
+9 -9
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
@@ -37,12 +37,12 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -65,7 +65,7 @@ jobs:
run: npx electron-vite build --mode e2e
- name: Upload E2E build output
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: e2e-build-out
path: out/
@@ -94,7 +94,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
@@ -111,12 +111,12 @@ jobs:
run: sudo apt-get install -y xvfb
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -136,7 +136,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
@@ -156,7 +156,7 @@ jobs:
# re-running locally.
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-traces-${{ matrix.shard_name }}
path: test-results/
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
contents: read
steps:
- name: Checkout orca
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Resolve tag and version
id: tag
@@ -113,7 +113,7 @@ jobs:
# installation token can.
- name: Generate buf0-bot token
id: app-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: 2590194
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
@@ -121,7 +121,7 @@ jobs:
repositories: homebrew-orca
- name: Checkout tap
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: stablyai/homebrew-orca
path: tap
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Apply OS label from issue form
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+5 -5
View File
@@ -23,15 +23,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -39,7 +39,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Setup JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -51,7 +51,7 @@ jobs:
run: cd android && ./gradlew assembleRelease
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: orca-mobile-apk
path: mobile/android/app/build/outputs/apk/release/*.apk
+3 -3
View File
@@ -20,15 +20,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
+9 -4
View File
@@ -14,18 +14,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -86,6 +86,11 @@ jobs:
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
# Why: install intentionally blocks Electron's package postinstall, but
# some unit tests import `electron` under Node and require path.txt.
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test
run: pnpm test
+23 -16
View File
@@ -64,13 +64,13 @@ jobs:
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
steps:
- name: Checkout ref
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
@@ -388,7 +388,14 @@ jobs:
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
fi
git commit -m "$commit_message"
if git diff --cached --quiet; then
# Why: a failed cut can push the version bump to main before the
# release is published. Re-cutting then needs a fresh taggable
# release commit even though package.json is already at VERSION.
git commit --allow-empty -m "$commit_message"
else
git commit -m "$commit_message"
fi
git tag -a "v$VERSION" -m "v$VERSION"
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
@@ -417,7 +424,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
@@ -461,19 +468,19 @@ jobs:
include:
- os: macos-15
platform: mac
release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
eb_cache_path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
- os: windows-latest
platform: win
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always'
eb_cache_path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
- os: ubuntu-latest
platform: linux
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
@@ -485,18 +492,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
@@ -519,7 +526,7 @@ jobs:
# Cache the Electron binary + electron-builder tool downloads (notarytool,
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
- name: Cache electron-builder downloads
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ matrix.eb_cache_path }}
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -531,7 +538,7 @@ jobs:
# occasionally returns 504s that fail the whole release. Retry on
# failure so transient network errors don't require a manual re-run.
- name: Install dependencies
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
@@ -622,7 +629,7 @@ jobs:
# don't require a manual re-run.
- name: Publish release artifacts (macOS)
if: matrix.platform == 'mac'
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 45
max_attempts: 3
@@ -638,7 +645,7 @@ jobs:
- name: Publish release artifacts
if: matrix.platform != 'mac'
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
max_attempts: 3
@@ -698,10 +705,10 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: package.json
+2 -2
View File
@@ -20,14 +20,14 @@ jobs:
steps:
- name: Generate bufo-bot token
id: app-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: 2590194
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
owner: stablyai
- name: Add PR to project
uses: actions/github-script@v7
uses: actions/github-script@v8
env:
PROJECT_OWNER: stablyai
PROJECT_NUMBER: '13'
+3 -1
View File
@@ -35,6 +35,7 @@ Orca supports any CLI agent (*not just this list*).
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
@@ -44,6 +45,7 @@ Orca supports any CLI agent (*not just this list*).
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
@@ -106,7 +108,7 @@ Control your agents from your phone.
</p>
- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9)
- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10)
---
+13 -1
View File
@@ -35,7 +35,7 @@ module.exports = {
'!resources/onboarding/feature-wall/**'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ (e.g. runtime-bootstrap). Both directories must be
// from out/shared/ and local hook mutators from out/main/. These paths must be
// unpacked so that Node's require() can resolve the cross-directory imports
// when the CLI runs outside the asar archive.
// Why: daemon-entry.js is forked as a separate Node.js process and must be
@@ -54,6 +54,17 @@ module.exports = {
asarUnpack: [
'out/cli/**',
'out/shared/**',
'out/main/agent-hooks/**',
'out/main/antigravity/**',
'out/main/claude/**',
'out/main/codex/**',
'out/main/copilot/**',
'out/main/cursor/**',
'out/main/droid/**',
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/computer-sidecar.js',
'out/main/chunks/**',
@@ -61,6 +72,7 @@ module.exports = {
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/yaml/**',
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37
View File
@@ -73,6 +73,43 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
+let consoleProcessList: number[];
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+} catch {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
--- a/src/unix/pty.cc
+31 -9
View File
@@ -49,7 +49,7 @@ function readRuntimeArg() {
}
function ensureNodeRuntime() {
const initial = runCurrentProcessCheck()
const initial = runNodeCheck()
if (initial.ok) {
return
}
@@ -61,7 +61,7 @@ function ensureNodeRuntime() {
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
const final = runCurrentProcessCheck()
const final = runNodeCheck()
if (!final.ok) {
console.error(
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
@@ -93,12 +93,16 @@ function ensureElectronRuntime() {
}
}
function runCurrentProcessCheck() {
const failures = collectNativeModuleFailures()
if (failures.length === 0) {
return { ok: true, failures }
}
return { ok: false, failures }
function runNodeCheck() {
// Why: a failed native addon load can poison the current process, so the
// post-rebuild verification must happen in a fresh Node process.
const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], {
cwd: projectDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
return parseChildCheckResult(result)
}
function runElectronCheck() {
@@ -119,15 +123,33 @@ function runElectronCheck() {
stdio: ['ignore', 'pipe', 'pipe']
})
return parseChildCheckResult(result)
}
function parseChildCheckResult(result) {
const failures = parseCheckFailures(result.stderr)
return {
ok: result.status === 0,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error
error: result.error,
failures
}
}
function parseCheckFailures(stderr) {
const failures = []
for (const line of (stderr ?? '').split(/\r?\n/)) {
const match = /^([^:]+):\s*(.*)$/.exec(line)
if (match && NATIVE_MODULES.includes(match[1])) {
failures.push({ moduleName: match[1], message: match[2] })
}
}
return failures
}
function collectNativeModuleFailures() {
const failures = []
for (const moduleName of NATIVE_MODULES) {
@@ -0,0 +1,135 @@
import { spawnSync } from 'node:child_process'
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
describe('ensure-native-runtime', () => {
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild better-sqlite3\n')
expect(log.split('\n').filter((line) => line.startsWith('better-sqlite3 '))).toEqual([
'better-sqlite3 child marker=false',
'better-sqlite3 child marker=true'
])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
return projectDir
}
function envWithPrependedPath(binDir, extraEnv) {
const pathKey =
process.platform === 'win32'
? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path')
: 'PATH'
return {
...process.env,
...extraEnv,
[pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`
}
}
function writeFakeNativeModules(projectDir) {
const sqliteDir = join(projectDir, 'node_modules', 'better-sqlite3')
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(sqliteDir, { recursive: true })
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(sqliteDir, 'index.js'),
`
const { appendFileSync, existsSync } = require('node:fs')
module.exports = class Database {
constructor() {
const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`better-sqlite3 \${process.argv.includes('--check-only') ? 'child' : 'parent'} marker=\${markerExists}\\n\`
)
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
}
close() {}
}
`
)
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
join(nodePtyDir, 'lib', 'utils.js'),
`
const { appendFileSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName}\\n\`
)
}
`
)
}
function writeFakePnpm(binDir) {
mkdirSync(binDir, { recursive: true })
const shimPath = join(binDir, 'pnpm-shim.cjs')
writeFileSync(
shimPath,
`
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
const posixPnpmPath = join(binDir, 'pnpm')
writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`)
chmodSync(posixPnpmPath, 0o755)
writeFileSync(
join(binDir, 'pnpm.cmd'),
`@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n`
)
}
@@ -0,0 +1,268 @@
#!/usr/bin/env node
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
const { version: electronVersion } = electronRequire('./package.json')
const { downloadArtifact } = electronRequire('@electron/get')
const platformPath = getElectronPlatformPath()
try {
// Why: Electron's own install.js can exit 0 while an async extract promise is
// still unsettled, leaving a partial dist/. Top-level await makes that fail.
await main()
} catch (error) {
console.error('[electron-package] Failed to install Electron package binary.')
console.error(error)
logElectronInstallDiagnostics()
process.exit(1)
}
async function main() {
if (electronPackageIsUsable()) {
return
}
// Why: PR tests run under system Node after native modules are rebuilt for
// 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.')
resetPartialElectronInstall()
await installElectronPackageBinary()
repairElectronPathFile()
if (!electronPackageIsUsable()) {
logElectronInstallDiagnostics()
console.error('[electron-package] Electron package is still unavailable after install.')
process.exit(1)
}
}
function electronPackageIsUsable() {
try {
const electronPath = resolveElectronPath()
return existsSync(electronPath)
} catch {
return false
}
}
function resolveElectronPath() {
const electronModulePath = require.resolve('electron')
delete require.cache[electronModulePath]
return require('electron')
}
function resetPartialElectronInstall() {
rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true })
rmSync(resolve(electronPackageDir, 'path.txt'), { force: true })
}
function repairElectronPathFile() {
const electronExecutable = resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronExecutable)) {
return
}
const pathFile = resolve(electronPackageDir, 'path.txt')
let currentPath = ''
try {
currentPath = readFileSync(pathFile, 'utf8')
} catch {
// Missing path.txt is the common CI failure this script repairs.
}
if (currentPath !== platformPath) {
writeFileSync(pathFile, platformPath)
console.log(`[electron-package] Repaired Electron path.txt -> ${platformPath}`)
}
}
async function installElectronPackageBinary() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const cacheRoot = join(tempDir, 'cache')
const extractDir = join(tempDir, 'extract')
try {
const zipPath = await downloadArtifact({
version: electronVersion,
artifactName: 'electron',
platform: process.env.npm_config_platform || osPlatform(),
arch: process.env.npm_config_arch || process.arch,
cacheRoot,
force: true,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
})
// Why: CI has observed partial extracts directly under node_modules/electron
// that leave only dist/locales. Verify in temp before replacing package dist.
extractElectronArchive(zipPath, extractDir)
const extractedExecutable = resolve(extractDir, platformPath)
if (!existsSync(extractedExecutable)) {
console.error('[electron-package] Electron archive extract did not contain executable.')
console.error(` platformPath=${platformPath}`)
console.error(` extractDir=${extractDir}`)
console.error(` extractEntries=${safeReaddir(extractDir).join(', ')}`)
process.exit(1)
}
moveExtractedElectronDist(extractDir, electronDistDir)
const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts')
if (existsSync(srcTypeDefPath)) {
renameSync(srcTypeDefPath, resolve(electronPackageDir, 'electron.d.ts'))
}
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
function extractElectronArchive(zipPath, extractDir) {
mkdirSync(extractDir, { recursive: true })
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
// promise and no active handles on CI. Host unzip tools fail synchronously.
const command = getExtractorCommand(zipPath, extractDir)
const result = spawnSync(command.file, command.args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(formatExtractorFailure(command, result))
}
}
function moveExtractedElectronDist(extractDir, electronDistDir) {
rmSync(electronDistDir, { recursive: true, force: true })
try {
// Why: macOS Electron archives rely on framework symlinks. Moving the
// verified tree preserves them exactly; copying has broken them in CI.
renameSync(extractDir, electronDistDir)
} catch (/** @type {any} */ err) {
if (err?.code !== 'EXDEV') {
throw err
}
cpSync(extractDir, electronDistDir, {
recursive: true,
dereference: false,
verbatimSymlinks: true
})
}
}
function getExtractorCommand(zipPath, extractDir) {
if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) {
return {
file: process.execPath,
args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir],
label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}`
}
}
if (osPlatform() === 'win32') {
return {
file: process.env.ORCA_POWERSHELL_BIN || 'powershell',
args: [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
[
"$ErrorActionPreference = 'Stop'",
`Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force`
].join('; ')
],
label: 'powershell Expand-Archive'
}
}
return {
file: process.env.ORCA_UNZIP_BIN || 'unzip',
args: ['-q', zipPath, '-d', extractDir],
label: 'unzip'
}
}
function quotePowerShellLiteral(value) {
return `'${String(value).replaceAll("'", "''")}'`
}
function formatExtractorFailure(command, result) {
return [
`[electron-package] ${command.label} failed with status ${result.status}.`,
result.stdout ? `stdout:\n${result.stdout.trim()}` : '',
result.stderr ? `stderr:\n${result.stderr.trim()}` : ''
]
.filter(Boolean)
.join('\n')
}
function shouldUseRemoteChecksums() {
return Boolean(
process.env.electron_use_remote_checksums ||
process.env.npm_config_electron_use_remote_checksums
)
}
function logElectronInstallDiagnostics() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const pathFile = resolve(electronPackageDir, 'path.txt')
console.error('[electron-package] Electron install diagnostics:')
console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`)
console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`)
console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`)
console.error(` platformPath=${platformPath}`)
if (existsSync(electronDistDir)) {
console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`)
}
}
function safeReaddir(targetPath) {
try {
return readdirSync(targetPath).slice(0, 40)
} catch {
return []
}
}
function getElectronPlatformPath() {
const targetPlatform = process.env.npm_config_platform || osPlatform()
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,177 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
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)
)
describe('install-electron-package-binary', () => {
it('installs Electron from an isolated cache and repairs path.txt', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir)
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toMatch(
/cacheRoot=.*orca-electron-.*cache/
)
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
'electron'
)
if (process.platform !== 'win32') {
expect(
lstatSync(
join(projectDir, 'node_modules', 'electron', 'dist', 'version-link')
).isSymbolicLink()
).toBe(true)
}
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails instead of silently accepting a partial Electron extract', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: false })
mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), {
recursive: true
})
writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path')
const result = runInstallScript(projectDir)
expect(result.status).toBe(1)
expect(result.stderr).toContain('Electron archive extract did not contain executable')
expect(result.stderr).toContain('extractEntries=locales')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not exit successfully when Electron download never settles', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { downloadNeverSettles: true })
writeFakeExtractor(projectDir, { createExecutable: false })
const result = runInstallScript(projectDir)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Detected unsettled top-level await')
expect(existsSync(join(projectDir, 'node_modules', 'electron', 'path.txt'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
})
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) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs')
}
})
}
function writeFakeElectronPackage(projectDir) {
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')
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 writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
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')
exports.downloadArtifact = async function downloadArtifact(details) {
appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n')
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
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, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
@@ -0,0 +1,87 @@
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
describe('Electron runtime package contract', () => {
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
})
it('guards package scripts that launch Electron tooling', () => {
const scripts = packageJson.scripts
const guardedScripts = [
'start',
'dev',
'dev-stable-name',
'build:unpack',
'build:win',
'build:mac',
'build:mac:release',
'build:linux',
'test:e2e',
'test:e2e:headful'
]
for (const scriptName of guardedScripts) {
expect(scripts[scriptName], scriptName).toContain('pnpm run ensure:electron-runtime &&')
}
})
it('guards release publishing before electron-builder runs', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const releaseCommands = new Map(
parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [
platform,
release_command
])
)
expect([...releaseCommands.keys()].sort()).toEqual(['linux', 'mac', 'win'])
for (const command of releaseCommands.values()) {
expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(command).toContain('electron-builder')
expect(command.indexOf('ensure-native-runtime')).toBeLessThan(
command.indexOf('electron-builder')
)
}
expect(releaseCommands.get('mac')).toContain(' && ORCA_MAC_RELEASE=1 ')
expect(releaseCommands.get('linux')).toContain(' && pnpm exec electron-builder ')
expect(releaseCommands.get('win')).toContain(
'; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder '
)
})
it('lets release-cut tag a version that is already present on main', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const bumpStep = parsedWorkflow.jobs.cut.steps.find(
(step) => step.name === 'Bump package.json and tag'
)
expect(bumpStep.run).toContain('git diff --cached --quiet')
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
})
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const installStep = parsedWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs')
})
})
@@ -0,0 +1,481 @@
/* eslint-disable no-control-regex -- Benchmarks mirror PTY ANSI/OSC parsing regexes. */
import { performance } from 'node:perf_hooks'
import v8 from 'node:v8'
const PTY_COUNT = Number.parseInt(process.env.ORCA_PTY_BENCH_PTY_COUNT ?? '24', 10)
const PAYLOAD_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_PAYLOAD_CHARS ?? '262144', 10)
const RUNS = Number.parseInt(process.env.ORCA_PTY_BENCH_RUNS ?? '30', 10)
const MEASURE_TIMER_DELAYS = process.env.ORCA_PTY_BENCH_MEASURE_TIMER_DELAYS !== '0'
const INGRESS_CHUNKS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHUNKS ?? '96', 10)
const INGRESS_CHUNK_CHARS = Number.parseInt(
process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536',
10
)
const CHUNK_CHARS = 16 * 1024
const MAX_WRITES_PER_SLICE = 2
const RECENT_PTY_OUTPUT_LIMIT = 4096
const MAX_TAIL_LINES = 2000
const MAX_TAIL_CHARS = 256 * 1024
const MAX_TAIL_PARTIAL_CHARS = 4000
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi
if (!Number.isInteger(PTY_COUNT) || PTY_COUNT <= 0) {
throw new Error(`ORCA_PTY_BENCH_PTY_COUNT must be positive, received ${PTY_COUNT}`)
}
if (!Number.isInteger(PAYLOAD_CHARS) || PAYLOAD_CHARS <= 0) {
throw new Error(`ORCA_PTY_BENCH_PAYLOAD_CHARS must be positive, received ${PAYLOAD_CHARS}`)
}
if (!Number.isInteger(RUNS) || RUNS <= 0) {
throw new Error(`ORCA_PTY_BENCH_RUNS must be positive, received ${RUNS}`)
}
if (!Number.isInteger(INGRESS_CHUNKS) || INGRESS_CHUNKS <= 0) {
throw new Error(
`ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}`
)
}
if (!Number.isInteger(INGRESS_CHUNK_CHARS) || INGRESS_CHUNK_CHARS <= 0) {
throw new Error(
`ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}`
)
}
function makePendingData() {
const pending = new Map()
for (let index = 0; index < PTY_COUNT; index++) {
pending.set(`pty-${index}`, `${index}:`.padEnd(PAYLOAD_CHARS, 'x'))
}
return pending
}
function simulateWebContentsSend(id, data) {
return v8.serialize({ channel: 'pty:data', payload: { id, data } }).byteLength
}
function flushLegacy(pending) {
let bytes = 0
const start = performance.now()
for (const [id, data] of pending) {
bytes += simulateWebContentsSend(id, data)
}
pending.clear()
return { bytes, durationMs: performance.now() - start }
}
function flushBoundedSlice(pending) {
let bytes = 0
let writes = 0
const start = performance.now()
while (pending.size > 0 && writes < MAX_WRITES_PER_SLICE) {
const next = pending.entries().next().value
if (!next) {
break
}
const [id, data] = next
pending.delete(id)
const chunk = data.slice(0, CHUNK_CHARS)
const remaining = data.slice(CHUNK_CHARS)
if (remaining) {
pending.set(id, remaining)
}
bytes += simulateWebContentsSend(id, chunk)
writes++
}
return { bytes, durationMs: performance.now() - start }
}
function drainBounded(pending) {
let bytes = 0
const sliceDurations = []
while (pending.size > 0) {
const result = flushBoundedSlice(pending)
bytes += result.bytes
sliceDurations.push(result.durationMs)
}
return { bytes, sliceDurations }
}
function makeIngressChunk() {
return `${'x'.repeat(INGRESS_CHUNK_CHARS - 1)}\n`
}
function extractLastOscTitleLegacy(data) {
let last = null
for (const m of data.matchAll(OSC_TITLE_RE)) {
last = m[2]
}
return last
}
function extractLastOscTitleCurrent(data) {
if (!data.includes('\x1b]')) {
return null
}
return extractLastOscTitleLegacy(data)
}
function hasMeaningfulContentLegacy(chunk) {
return (
chunk
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[@-_]/g, '')
.replace(/\u0008/g, '')
.replace(/[^\x09\x0a\x20-\x7e]/g, '')
.trim().length > 0
)
}
function hasMeaningfulContentCurrent(chunk) {
for (let index = 0; index < chunk.length; index++) {
const code = chunk.charCodeAt(index)
if (code === 0x1b || code < 0x09 || (code > 0x0d && code < 0x20) || code > 0x7e) {
break
}
if (code > 0x20) {
return true
}
}
return hasMeaningfulContentLegacy(chunk)
}
function normalizeTerminalChunkLegacy(chunk) {
return chunk
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[@-_]/g, '')
.replace(/\u0008/g, '')
.replace(/[^\x09\x0a\x20-\x7e]/g, '')
}
function terminalChunkNeedsNormalization(chunk) {
for (let index = 0; index < chunk.length; index++) {
const code = chunk.charCodeAt(index)
if (
code === 0x1b ||
code === 0x0d ||
code < 0x09 ||
(code > 0x0a && code < 0x20) ||
code > 0x7e
) {
return true
}
}
return false
}
function normalizeTerminalChunkCurrent(chunk) {
return terminalChunkNeedsNormalization(chunk) ? normalizeTerminalChunkLegacy(chunk) : chunk
}
function appendNormalizedToTailBuffer(previousLines, previousPartialLine, normalizedChunk) {
if (normalizedChunk.length === 0) {
return {
lines: previousLines,
partialLine: previousPartialLine
}
}
const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`.split('\n')
const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '')
const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
let nextLines =
pieces.length > 0
? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
: previousLines
while (nextLines.length > MAX_TAIL_LINES) {
nextLines.shift()
}
if (pieces.length > 0 || retainedPartialLine.length > previousPartialLine.length) {
if (nextLines === previousLines) {
nextLines = [...previousLines]
}
let totalChars =
nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length
while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) {
totalChars -= nextLines.shift().length
}
}
return {
lines: nextLines,
partialLine: retainedPartialLine
}
}
function appendTailBufferLegacy(previousLines, previousPartialLine, chunk) {
return appendNormalizedToTailBuffer(
previousLines,
previousPartialLine,
normalizeTerminalChunkLegacy(chunk)
)
}
function tailStateMatches(lines, partialLine, snapshot) {
if (
partialLine !== snapshot.partialLine ||
lines.length !== snapshot.lines.length ||
lines.length !== snapshot.linesTotal
) {
return false
}
if (lines === snapshot.lines) {
return true
}
for (let index = 0; index < lines.length; index++) {
if (lines[index] !== snapshot.lines[index]) {
return false
}
}
return true
}
class AdvertisedUrlPtyBuffer {
raw = ''
ingest(chunk) {
this.raw += chunk
if (this.raw.length > 4096) {
this.raw = this.raw.slice(-4096)
}
const lastLineBreak = Math.max(this.raw.lastIndexOf('\n'), this.raw.lastIndexOf('\r'))
if (lastLineBreak === -1) {
return ''
}
const finalized = this.raw.slice(0, lastLineBreak + 1)
this.raw = this.raw.slice(lastLineBreak + 1)
return finalized
}
}
function scanAdvertisedUrls(buffer, chunk) {
const finalized = buffer.ingest(chunk)
if (!finalized) {
return
}
for (const _match of finalized.matchAll(URL_CANDIDATE_PATTERN)) {
// Candidate extraction is enough for this benchmark; URL validation is rare
// and only happens after a URL-shaped match.
}
}
function measureRuntimeIngressLegacy() {
const chunk = makeIngressChunk()
const advertisedUrlBuffer = new AdvertisedUrlPtyBuffer()
let recentOutput = ''
let ptyTail = { lines: [], partialLine: '' }
let leafTail = { lines: [], partialLine: '' }
const start = performance.now()
for (let index = 0; index < INGRESS_CHUNKS; index++) {
recentOutput = `${recentOutput}${chunk}`.slice(-RECENT_PTY_OUTPUT_LIMIT)
hasMeaningfulContentLegacy(chunk)
extractLastOscTitleLegacy(chunk)
scanAdvertisedUrls(advertisedUrlBuffer, chunk)
extractLastOscTitleLegacy(chunk)
ptyTail = appendTailBufferLegacy(ptyTail.lines, ptyTail.partialLine, chunk)
leafTail = appendTailBufferLegacy(leafTail.lines, leafTail.partialLine, chunk)
}
return performance.now() - start
}
function measureRuntimeIngressCurrent() {
const chunk = makeIngressChunk()
const advertisedUrlBuffer = new AdvertisedUrlPtyBuffer()
let recentOutput = ''
let ptyTail = { lines: [], partialLine: '' }
let leafTail = { lines: [], partialLine: '' }
const start = performance.now()
for (let index = 0; index < INGRESS_CHUNKS; index++) {
recentOutput = `${recentOutput}${chunk}`.slice(-RECENT_PTY_OUTPUT_LIMIT)
hasMeaningfulContentCurrent(chunk)
extractLastOscTitleCurrent(chunk)
scanAdvertisedUrls(advertisedUrlBuffer, chunk)
extractLastOscTitleCurrent(chunk)
const normalizedChunk = normalizeTerminalChunkCurrent(chunk)
const ptyTailBefore = {
lines: ptyTail.lines,
partialLine: ptyTail.partialLine,
linesTotal: ptyTail.lines.length
}
ptyTail = appendNormalizedToTailBuffer(ptyTail.lines, ptyTail.partialLine, normalizedChunk)
leafTail = tailStateMatches(leafTail.lines, leafTail.partialLine, ptyTailBefore)
? { lines: ptyTail.lines, partialLine: ptyTail.partialLine }
: appendNormalizedToTailBuffer(leafTail.lines, leafTail.partialLine, normalizedChunk)
}
return performance.now() - start
}
function scheduleLegacyFlush(pending) {
return new Promise((resolve) => {
setTimeout(() => {
flushLegacy(pending)
resolve()
}, 0)
})
}
function scheduleBoundedFlush(pending) {
return new Promise((resolve) => {
const drain = () => {
flushBoundedSlice(pending)
if (pending.size > 0) {
setTimeout(drain, 1)
return
}
resolve()
}
setTimeout(drain, 0)
})
}
async function measureInputTimerDelay(flushKind) {
const pending = makePendingData()
const scheduledAt = performance.now()
const drainPromise =
flushKind === 'legacy' ? scheduleLegacyFlush(pending) : scheduleBoundedFlush(pending)
const inputDelay = await new Promise((resolve) => {
setTimeout(() => resolve(performance.now() - scheduledAt), 0)
})
await drainPromise
return inputDelay
}
function percentile(values, p) {
const sorted = [...values].sort((a, b) => a - b)
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[index] ?? 0
}
function summarize(values) {
return {
median: percentile(values, 50),
p95: percentile(values, 95),
max: Math.max(...values)
}
}
function formatMs(value) {
return `${value.toFixed(3)}ms`
}
async function runBenchmark() {
const legacyDurations = []
const boundedFirstSliceDurations = []
const boundedMaxSliceDurations = []
const boundedTotalDurations = []
const legacyInputTimerDelays = []
const boundedInputTimerDelays = []
const legacyRuntimeIngressDurations = []
const currentRuntimeIngressDurations = []
let legacyBytes = 0
let boundedBytes = 0
for (let run = 0; run < RUNS; run++) {
const legacy = flushLegacy(makePendingData())
legacyDurations.push(legacy.durationMs)
legacyBytes = legacy.bytes
const boundedStart = performance.now()
const bounded = drainBounded(makePendingData())
boundedBytes = bounded.bytes
boundedFirstSliceDurations.push(bounded.sliceDurations[0] ?? 0)
boundedMaxSliceDurations.push(Math.max(...bounded.sliceDurations))
boundedTotalDurations.push(performance.now() - boundedStart)
if (MEASURE_TIMER_DELAYS) {
legacyInputTimerDelays.push(await measureInputTimerDelay('legacy'))
boundedInputTimerDelays.push(await measureInputTimerDelay('bounded'))
}
legacyRuntimeIngressDurations.push(measureRuntimeIngressLegacy())
currentRuntimeIngressDurations.push(measureRuntimeIngressCurrent())
}
const legacySummary = summarize(legacyDurations)
const boundedFirstSliceSummary = summarize(boundedFirstSliceDurations)
const boundedMaxSliceSummary = summarize(boundedMaxSliceDurations)
const boundedTotalSummary = summarize(boundedTotalDurations)
const legacyInputTimerSummary = MEASURE_TIMER_DELAYS ? summarize(legacyInputTimerDelays) : null
const boundedInputTimerSummary = MEASURE_TIMER_DELAYS ? summarize(boundedInputTimerDelays) : null
const legacyRuntimeIngressSummary = summarize(legacyRuntimeIngressDurations)
const currentRuntimeIngressSummary = summarize(currentRuntimeIngressDurations)
console.log(
JSON.stringify(
{
scenario: {
ptyCount: PTY_COUNT,
payloadChars: PAYLOAD_CHARS,
runs: RUNS,
totalPayloadMiB: (PTY_COUNT * PAYLOAD_CHARS) / 1024 / 1024,
ingressChunks: INGRESS_CHUNKS,
ingressChunkChars: INGRESS_CHUNK_CHARS,
ingressPayloadMiB: (INGRESS_CHUNKS * INGRESS_CHUNK_CHARS) / 1024 / 1024
},
legacy: {
bytesPerRun: legacyBytes,
singleCallback: legacySummary,
inputTimerDelay: legacyInputTimerSummary
},
bounded: {
bytesPerRun: boundedBytes,
firstSlice: boundedFirstSliceSummary,
maxSlice: boundedMaxSliceSummary,
totalDrain: boundedTotalSummary,
inputTimerDelay: boundedInputTimerSummary
},
runtimeIngress: {
legacyRepeatedScans: legacyRuntimeIngressSummary,
currentFastPath: currentRuntimeIngressSummary,
estimatedReduction:
legacyRuntimeIngressSummary.max /
Math.max(currentRuntimeIngressSummary.max, Number.EPSILON)
},
estimatedPtyWriteDelay:
legacyInputTimerSummary && boundedInputTimerSummary
? {
before: legacyInputTimerSummary.max,
after: boundedInputTimerSummary.max,
reduction:
legacyInputTimerSummary.max /
Math.max(boundedInputTimerSummary.max, Number.EPSILON)
}
: null
},
null,
2
)
)
console.error(
[
`legacy max single callback: ${formatMs(legacySummary.max)}`,
`bounded max first slice: ${formatMs(boundedFirstSliceSummary.max)}`,
`bounded max any slice: ${formatMs(boundedMaxSliceSummary.max)}`,
`bounded max total drain: ${formatMs(boundedTotalSummary.max)}`,
`runtime ingress legacy max: ${formatMs(legacyRuntimeIngressSummary.max)}`,
`runtime ingress current max: ${formatMs(currentRuntimeIngressSummary.max)}`,
legacyInputTimerSummary
? `legacy max input timer delay: ${formatMs(legacyInputTimerSummary.max)}`
: null,
boundedInputTimerSummary
? `bounded max input timer delay: ${formatMs(boundedInputTimerSummary.max)}`
: null
]
.filter(Boolean)
.join('\n')
)
}
await runBenchmark()
+161 -1
View File
@@ -22,7 +22,8 @@
import { rebuild } from '@electron/rebuild'
import { execFileSync, spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { platform as osPlatform } from 'node:os'
import { resolve } from 'node:path'
const require = createRequire(import.meta.url)
@@ -45,6 +46,8 @@ const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1'
ensureElectronPackageInstalled()
if (!forceRebuild) {
// Why: Windows cannot unlink a loaded .node DLL, so avoid @electron/rebuild
// when the current install already works with Electron's ABI.
@@ -123,6 +126,159 @@ try {
process.exit(1)
}
function ensureElectronPackageInstalled() {
try {
require('electron')
return
} catch (/** @type {any} */ err) {
if (!isElectronPackageInstallError(err)) {
throw err
}
}
// Why: CI has observed Electron's postinstall exiting cleanly without
// writing path.txt; use our strict installer instead of Electron's install.js
// so partial extracts and poisoned caches are rejected before native rebuild.
console.log('[rebuild] Electron package binary is missing; installing Electron package binary.')
resetPartialElectronInstall()
try {
runElectronPackageBinaryInstall()
} catch (/** @type {any} */ err) {
console.error('[rebuild] Electron install retry failed:', err?.message ?? err)
logElectronInstallDiagnostics()
if (continuePostinstallWithoutElectron()) {
process.exit(0)
}
process.exit(1)
}
try {
require('electron')
} catch (/** @type {any} */ err) {
if (!repairElectronPathFile()) {
logElectronInstallDiagnostics()
if (continuePostinstallWithoutElectron()) {
process.exit(0)
}
console.error(
'[rebuild] Electron package is still unavailable after retry:',
err?.message ?? err
)
process.exit(1)
}
try {
require('electron')
} catch (/** @type {any} */ retryErr) {
console.error(
'[rebuild] Electron package is still unavailable after repairing path.txt:',
retryErr?.message ?? retryErr
)
process.exit(1)
}
}
}
function runElectronPackageBinaryInstall() {
const env = { ...process.env }
delete env.ELECTRON_SKIP_BINARY_DOWNLOAD
delete env.npm_config_electron_skip_binary_download
const result = spawnSync(
process.execPath,
['config/scripts/install-electron-package-binary.mjs'],
{
cwd: projectDir,
env,
stdio: 'inherit'
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(
`config/scripts/install-electron-package-binary.mjs exited with status ${result.status}`
)
}
}
function resetPartialElectronInstall() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
// Why: Electron's installer can leave a partial dist/ tree behind after
// skipped or interrupted postinstall runs; retry from a clean target.
rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true })
rmSync(resolve(electronPackageDir, 'path.txt'), { force: true })
}
function continuePostinstallWithoutElectron() {
if (!isPostinstall() || process.env.ORCA_STRICT_ELECTRON_INSTALL === '1') {
return false
}
console.error(
'[rebuild] Continuing postinstall because Electron binary installation failed. ' +
'Electron-consuming package scripts and release jobs run ' +
'config/scripts/ensure-native-runtime.mjs --runtime=electron before launching Electron.'
)
return true
}
function repairElectronPathFile() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const platformPath = getElectronPlatformPath()
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronPath)) {
return false
}
// Why: Electron's install script has exited successfully in CI after
// extraction without leaving path.txt. The package main only needs this file
// to point at the already-extracted executable.
writeFileSync(resolve(electronPackageDir, 'path.txt'), platformPath)
console.log(`[rebuild] Repaired Electron path.txt -> ${platformPath}`)
return true
}
function logElectronInstallDiagnostics() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronDistDir = resolve(electronPackageDir, 'dist')
const pathFile = resolve(electronPackageDir, 'path.txt')
console.error('[rebuild] Electron install diagnostics:')
console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`)
console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`)
console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`)
if (existsSync(electronDistDir)) {
console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`)
}
}
function safeReaddir(targetPath) {
try {
return readdirSync(targetPath).slice(0, 20)
} catch {
return []
}
}
function getElectronPlatformPath() {
const targetPlatform = process.env.npm_config_platform || osPlatform()
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}`)
}
}
function probeElectronNativeModules(moduleNames) {
let electronExecutable
try {
@@ -217,6 +373,10 @@ function isPostinstall() {
return process.env.npm_lifecycle_event === 'postinstall'
}
function isElectronPackageInstallError(error) {
return /Electron failed to install correctly/i.test(formatError(error))
}
function formatError(error) {
return error instanceof Error ? error.message : String(error)
}
+233
View File
@@ -0,0 +1,233 @@
import { spawnSync } from 'node:child_process'
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
const sourceInstallScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
describe('rebuild-native-deps Electron install fallback', () => {
it('continues non-strict postinstall when Electron retry download fails', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { downloadRejects: true })
writeFakeExtractZip(projectDir, { createExecutable: false })
writeFakeElectronRebuild(projectDir)
const result = runRebuildScript(projectDir, {
npm_lifecycle_event: 'postinstall',
ORCA_STRICT_ELECTRON_INSTALL: ''
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain('Electron install retry failed')
expect(result.stderr).toContain(
'Continuing postinstall because Electron binary installation failed'
)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe(
'download attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails strict postinstall when Electron retry download fails', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { downloadRejects: true })
writeFakeExtractZip(projectDir, { createExecutable: false })
writeFakeElectronRebuild(projectDir)
const result = runRebuildScript(projectDir, {
npm_lifecycle_event: 'postinstall',
ORCA_STRICT_ELECTRON_INSTALL: '1'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Electron install retry failed')
expect(result.stderr).not.toContain(
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails non-postinstall rebuild commands when Electron retry download fails', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { downloadRejects: true })
writeFakeExtractZip(projectDir, { createExecutable: false })
writeFakeElectronRebuild(projectDir)
const result = runRebuildScript(projectDir)
expect(result.status).toBe(1)
expect(result.stderr).toContain('Electron install retry failed')
expect(result.stderr).not.toContain(
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('clears partial Electron package contents before retrying install', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { logPartialStateBeforeInstall: true })
writeFakeExtractZip(projectDir, { createExecutable: false })
writeFakeElectronRebuild(projectDir)
mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), {
recursive: true
})
writeFileSync(
join(projectDir, 'node_modules', 'electron', 'dist', 'locales', 'stale.pak'),
''
)
writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path')
const result = runRebuildScript(projectDir, {
ORCA_STRICT_ELECTRON_INSTALL: '1'
})
expect(result.status).toBe(1)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe(
'partial cleared\ndownload attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
})
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')
)
return projectDir
}
function runRebuildScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir) {
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')
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')
}
const electronPath = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
if (!fs.existsSync(electronPath)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = electronPath
`
)
}
function writeFakeElectronGet(
projectDir,
{ downloadRejects = false, logPartialStateBeforeInstall = false } = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { appendFileSync, existsSync, mkdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
exports.downloadArtifact = async function downloadArtifact(details) {
if (${JSON.stringify(logPartialStateBeforeInstall)}) {
appendFileSync(
'electron-get.log',
existsSync('node_modules/electron/dist') || existsSync('node_modules/electron/path.txt')
? 'partial still present\\n'
: 'partial cleared\\n'
)
}
appendFileSync('electron-get.log', 'download attempted\\n')
if (${JSON.stringify(downloadRejects)}) {
throw new Error('download failed')
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
function writeFakeExtractZip(projectDir, { createExecutable }) {
const extractDir = join(projectDir, 'node_modules', 'electron', 'node_modules', 'extract-zip')
mkdirSync(extractDir, { recursive: true })
writeFileSync(
join(extractDir, 'index.js'),
`
const { mkdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
module.exports = async function extract(_zipPath, options) {
mkdirSync(join(options.dir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(options.dir, 'electron'), '')
writeFileSync(join(options.dir, 'version'), 'v41.5.0')
}
}
`
)
chmodSync(join(extractDir, 'index.js'), 0o755)
}
function writeFakeElectronRebuild(projectDir) {
const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild')
mkdirSync(rebuildDir, { recursive: true })
writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' }))
writeFileSync(join(rebuildDir, 'index.js'), 'export async function rebuild() {}\n')
}
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
extractManifestAssetNames,
getRequiredReleaseAssetNames,
verifyRequiredReleaseAssets
} from './verify-release-required-assets.mjs'
function jsonResponse(body) {
return {
ok: true,
status: 200,
statusText: 'OK',
json: vi.fn(async () => body),
text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
}
}
function releaseWithAssets(tag, assetNames) {
return {
tag_name: tag,
draft: true,
prerelease: false,
assets: assetNames.map((name, index) => ({
id: index + 1,
name,
state: 'uploaded',
size: 123
}))
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('getRequiredReleaseAssetNames', () => {
it('includes both mac updater ZIP names for the tag version', () => {
expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual(
expect.arrayContaining([
'Orca-1.4.27-mac.zip',
'Orca-1.4.27-mac.zip.blockmap',
'Orca-1.4.27-arm64-mac.zip',
'Orca-1.4.27-arm64-mac.zip.blockmap'
])
)
})
})
describe('extractManifestAssetNames', () => {
it('extracts relative and absolute manifest asset names', () => {
expect(
extractManifestAssetNames(
[
'files:',
' - url: Orca-1.4.27-arm64-mac.zip',
' - url: https://example.com/downloads/orca-windows-setup.exe',
'path: orca-linux.AppImage'
].join('\n')
)
).toEqual(['Orca-1.4.27-arm64-mac.zip', 'orca-windows-setup.exe', 'orca-linux.AppImage'])
})
})
describe('verifyRequiredReleaseAssets', () => {
it('fails when a manifest-referenced asset has not been uploaded', async () => {
const tag = 'v1.4.27'
const required = getRequiredReleaseAssetNames(tag)
const assets = required.filter((name) => name !== 'Orca-1.4.27-arm64-mac.zip')
const release = releaseWithAssets(tag, assets)
const latestMacAsset = release.assets.find((asset) => asset.name === 'latest-mac.yml')
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release]))
.mockResolvedValueOnce(
jsonResponse(
[
'version: 1.4.27',
'files:',
' - url: Orca-1.4.27-arm64-mac.zip',
' sha512: test',
'path: Orca-1.4.27-arm64-mac.zip'
].join('\n')
)
)
.mockResolvedValue(jsonResponse('version: 1.4.27\n'))
vi.stubGlobal('fetch', fetchMock)
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' })
).rejects.toThrow('Missing: Orca-1.4.27-arm64-mac.zip')
expect(latestMacAsset).toBeTruthy()
})
})
+24 -1
View File
@@ -1,6 +1,29 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["../src/cli/**/*", "../src/shared/**/*", "../src/main/runtime/runtime-metadata.ts"],
"include": [
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/grok/hook-service.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
"compilerOptions": {
"composite": true,
"module": "CommonJS",
+50
View File
@@ -0,0 +1,50 @@
# Failed Automation Rerun Action
## Problem
- Failed Orca automation run details show only the disabled/open-target action when no workspace launched, so a user who sees a `dispatch_failed` error has no local recovery action in the failed-run view: `src/renderer/src/components/automations/AutomationsPage.tsx:1858`.
- The reusable detail header already accepts actions, but the run detail action set is limited to `getAutomationRunViewState`/`openRunWorkspace`: `src/renderer/src/components/automations/AutomationRunPageFrame.tsx:9`, `src/renderer/src/components/automations/automation-run-view-state.ts:13`.
- Manual rerun behavior already exists as `runNow`, which creates a fresh manual run for the automation; the renderer wrapper currently refreshes the page after calling it: `src/renderer/src/components/automations/AutomationsPage.tsx:1033`, `src/main/automations/service.ts:66`.
## Goal
Add an easy rerun button to Orca automation run detail pages for failed launch/recovery statuses. The button creates a fresh manual run for the same automation through the existing `runNow` path.
## Non-goals
- Do not mutate or replay the failed run record.
- Do not add backend APIs or provider-specific rerun behavior.
- Do not add rerun support for external Hermes/OpenClaw run details in this change.
- Do not bypass existing SSH availability, worktree creation, or dispatch failure handling.
## Design
1. Add a small pure predicate near the run view state code, for example `canRerunAutomationRun({ automation, run })`.
- Return `true` only when an Orca automation still exists, `run.automationId === automation.id`, and the run status is one of `dispatch_failed`, `skipped_unavailable`, or `skipped_needs_interactive_auth`.
- Do not show rerun for `pending`, `dispatching`, `dispatched`, `completed`, or `skipped_missed`. `skipped_missed` is scheduler catch-up history, not a launch failure the detail page should recover.
2. In `AutomationsPage`, render a `Rerun` action in `AutomationRunPageFrame.actions` when that predicate is true for `selected` and `selectedAutomationRunPage`.
3. The action calls `window.api.automations.runNow({ id: selected.id })` through a small handler, then `refresh()`, then shows the existing queued toast. Wrap the call in `try/catch/finally`: if the automation was deleted or the IPC rejects, toast the error and refresh so the stale run detail can disappear.
4. Preserve dispatch behavior by reusing `runNow`; do not duplicate local/SSH dispatch logic in the renderer. `runNow` creates a new manual run and `AutomationService.requestDispatch` sends the existing `automations:dispatchRequested` payload to the renderer, where `useAutomationDispatchEvents` handles SSH reconnect, interactive-auth skips, worktree creation/reuse, and terminal launch.
5. Add a local in-flight state keyed by the failed run id. Disable only that rerun button while pending so repeated clicks in one window cannot queue duplicates before the first request settles.
6. Keep the existing `View run` / `Open workspace` action beside the new rerun action. Do not gate rerun on `selectedAutomationRunPageViewState.canOpen`; failed runs with no launch should still show the disabled view action plus enabled rerun.
7. Use existing shadcn `Button`, lucide icon sizing, and styleguide tokens. The button should be compact (`size="sm"`), outline-level emphasis, and fit the existing header action row.
8. Add focused tests for the pure predicate in `automation-run-view-state.test.ts`. Avoid component-level tests unless a harness already exists locally.
## Edge cases
- Failed run has no workspace because base ref refresh, SSH connection, missing project/workspace, or renderer availability failed: rerun remains available and lets the existing dispatch path try again.
- Failed run has a workspace but terminal is closed: rerun stays available, and open workspace behavior remains unchanged.
- Automation was deleted while the request is in flight or by another window: `runNow` can throw `Automation not found.`; catch it, clear pending state, and refresh.
- Selection changed while the request is in flight: capture `automation.id` and `run.id` before awaiting, clear that run id in `finally`, and avoid reading mutable `selected` after the await.
- SSH automation cannot reconnect or needs credentials: rerun still goes through `runNow`; existing dispatch code records `skipped_unavailable` or `skipped_needs_interactive_auth` as a new run.
- The user clicks rerun repeatedly in the same window: disable the rerun button while the request is pending.
- The user clicks rerun in two windows: the renderer guard will not prevent duplicate manual runs across windows. Do not claim backend idempotency unless a service-level dedupe key is added.
- Refresh after rerun may leave the failed run detail selected because `selectedAutomationRunPageId` still points at the old run. That is acceptable for this change, but the run list count and rows must refresh so the new manual run is visible after navigating back.
- `runNow` itself does not broadcast `orca:automations-changed`; the initiating page must call `refresh()` after the IPC resolves. Other windows update only when dispatch result handling fires the existing event or when focus/visibility refresh runs.
## Rollout
1. Add a small predicate/helper for rerun action visibility and unit tests if it can live near automation run view state without mixing responsibilities.
2. Add rerun pending state, error handling, and handler in `AutomationsPage`.
3. Render the new action in the selected Orca run detail header.
4. Run focused tests, then `pnpm typecheck` and `pnpm lint`.
+79
View File
@@ -0,0 +1,79 @@
# New Worktree Sidebar Reveal
## Problem
Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation.
Current behavior:
- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options.
- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`).
- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`.
Result: off-screen targets animate by default, including freshly created worktrees.
## Root Cause
`activateAndRevealWorktree` conflates two intents:
1. activate existing worktree navigation (smooth reveal is fine);
2. activate a just-created worktree (must jump immediately).
Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`.
## Scope and Non-goals
- Add an opt-in reveal behavior at activation call sites.
- Apply `'auto'` only where the worktree is newly created/added in that flow.
- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in.
- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth).
- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling.
## Design
1. Extend `activateAndRevealWorktree` options:
- `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`.
2. In `activateAndRevealWorktree`, call:
- `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided;
- otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged.
3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows:
- `useComposerState` full-create path;
- `useComposerState` quick-create path;
- `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree;
- `launch-work-item-direct`;
- folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path).
4. Keep existing navigation activations smooth, including:
- `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created);
- all existing `activateAndRevealWorktree(...)` callers that do not opt in.
5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`.
## Correctness Notes and Edge Cases
- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded.
- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending.
- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution.
- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior.
- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued).
- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations.
- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally.
- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators.
## Tests
Add/adjust focused tests in `worktree-activation` coverage:
- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`;
- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth).
Add call-site regression tests (recommended, small):
- one composer create path passes `'auto'`;
- one non-created navigation path stays default (no behavior option).
## Rollout
1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`.
2. Update created-worktree callers to pass `'auto'`.
3. Add tests above.
4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`.
5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation.
@@ -0,0 +1,89 @@
# Orchestration Reset Scope Validation
## Problem
`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024).
## Root Cause
`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch.
## Non-goals
- Redesign orchestration storage or reset semantics.
- Add confirmation prompts to the RPC protocol.
- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior.
- Change unrelated orchestration commands or provider-specific behavior.
## Design
1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`.
2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state.
3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls.
4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method.
5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain.
6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation.
## Data Flow
- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`.
- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects.
- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects.
## Edge Cases
- `{}` rejects and leaves messages and tasks unchanged.
- `{ all: false }` rejects and leaves messages and tasks unchanged.
- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged.
- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged.
- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes.
- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present.
- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work.
- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state.
- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix.
## Test Plan
- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions.
- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior.
- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly.
- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI.
- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files.
- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots.
## UI Quality Bar
Not UI-visible.
## Review Screenshots
No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior.
## Rollout
1. Tighten `ResetParams` validation.
2. Simplify the reset handler to trust validated single-scope params.
3. Make CLI no-flag reset pass `all: true` explicitly.
4. Add RPC regression tests for invalid params preserving state.
5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough.
6. Run targeted tests, typecheck, and lint.
## Lightweight Eng Review
- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes.
- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`.
- Failure modes covered:
- Empty reset params cannot mutate state.
- Contradictory scopes cannot partially apply the first truthy branch.
- Failed validation happens before any database reset method runs.
- CLI shorthand cannot rely on a dangerous RPC fallback.
- CLI multi-flag input fails through the same RPC validation as any other caller.
- Test coverage required:
- `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state.
- `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing.
- `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented.
- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked.
- UI quality bar: Not UI-visible.
- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior.
- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller.
- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change.
+2
View File
@@ -37,6 +37,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
@@ -46,6 +47,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
+2
View File
@@ -37,6 +37,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
@@ -46,6 +47,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
+2
View File
@@ -37,6 +37,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
@@ -46,6 +47,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
+2
View File
@@ -37,6 +37,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
@@ -46,6 +47,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
@@ -1,6 +1,6 @@
# Feature Discovery Interaction Tracking
This document defines local feature-interaction state used to decide whether Orca should still teach a feature with a contextual tour or feature tip.
This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip.
## Decision
@@ -53,9 +53,9 @@ Do not upload this state as broad analytics. Product analytics should continue t
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
## Existing Contextual Tour Features
## Surface-Level Features
The branch already records surface-level interactions for the current contextual tours:
Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal:
- `workspace-board`: workspace board opened
- `browser`: non-blank browser page viewed
@@ -0,0 +1,66 @@
# Keyboard Layout Shortcut Dispatch
## Problem
Keyboard shortcuts must follow the user's active keyboard layout. A shortcut like `Cmd+W`
means "Command plus the key that produces `w`", not "Command plus the physical key labeled
W on a US keyboard." Physical-position matching breaks Dvorak, Colemak, AZERTY, JIS, and
other non-US layouts, and it also makes user keybinding overrides impossible to reason about.
## Decision
Orca app shortcuts dispatch by logical key by default.
The shared keybinding registry in `src/shared/keybindings.ts` is the source of truth for
app commands, configurable commands, shortcut recording, labels, conflict detection, browser
guest forwarding, and terminal pane commands. Code handling a user-facing app command must
call `keybindingMatchesAction`, `keybindingMatchesInput`, or a policy function built on those
helpers.
Physical `KeyboardEvent.code` may only decide a shortcut when the key is layout-invariant or
the platform cannot provide a real logical key.
Allowed physical-code uses:
- Modifier key release tracking, such as left/right Control release for held `Ctrl+Tab`.
- Layout-invariant keys, such as arrows, Tab, Enter, Escape, Backspace, Delete, Insert,
PageUp, PageDown, and explicit numpad bindings.
- Dead, unidentified, or missing logical keys where `KeyboardEvent.key` cannot describe the
produced key.
- Terminal byte encoding where the intent is a physical terminal escape sequence rather than
an Orca command.
Disallowed physical-code uses:
- Letter shortcuts for app actions.
- Punctuation shortcuts for app actions when `KeyboardEvent.key` reports the produced
punctuation.
- Clipboard shortcuts that are exposed as app or terminal UI commands.
- Hardcoded undo/redo/new/close/copy/paste handling outside the shared registry.
## Terminal Boundary
Terminal handling has two different jobs:
1. Orca commands that act on terminal UI, such as copy selection, paste, search, clear,
pane focus, split, and close. These are app shortcuts and must be layout-aware.
2. Bytes sent to the shell, such as readline escapes and Option-as-Alt sequences. These may
use physical key positions when terminal compatibility requires it.
This boundary is intentional. It lets non-US layouts use Orca commands naturally while
preserving shell behavior where users expect physical terminal-control sequences.
## Regression Requirements
Shortcut tests must cover both directions of a non-QWERTY swap:
- The key that produces the configured logical character must match, even if its physical code
differs.
- The physical US key must not match when it produces a different logical character.
Tests must also cover intentional exceptions:
- Dead or missing key fallback.
- Shifted punctuation aliases.
- Numpad-specific bindings.
- Terminal byte-encoding paths that intentionally use physical codes.
+74
View File
@@ -0,0 +1,74 @@
# Memory Leak Audit
Started: 2026-05-26 18:36 PDT
Objective: scan the entire Orca codebase for memory leaks, fix confirmed leaks, and keep durable coverage notes until the repository-wide scan is complete.
## Codebase Inventory
Tracked code files, from `git ls-files`:
| Bucket | Files | Status |
| ----------------- | ----: | ------------------------------------------- |
| `src/renderer` | 1534 | Checked |
| `src/main` | 838 | Checked |
| `src/shared` | 220 | Checked |
| `mobile/src` | 94 | Checked |
| `tests/e2e` | 71 | Checked |
| `src/relay` | 65 | Checked |
| `src/cli` | 64 | Checked |
| `config/scripts` | 27 | Checked |
| `mobile/app` | 16 | Checked |
| `other-code` | 13 | Checked |
| `mobile/packages` | 12 | Checked |
| `native` | 6 | Checked |
| `src/preload` | 6 | Checked by IPC subscription heuristic |
| Total | 2966 | Repository-wide risk-pattern scan completed |
## Scan Log
- 2026-05-26: Loaded `$memory-leak-audit` checklist and built an initial inventory from `git ls-files`.
- 2026-05-26: Ran a broad listener/timer/observer search across `src`, `mobile`, `tests`, and `config`.
- 2026-05-26: Ran a React `useEffect` cleanup heuristic over 1845 TS/TSX files. Manual follow-up identified one confirmed issue in `src/renderer/src/components/editor/PdfViewer.tsx`: `EventBus.on('scalechanging', ...)` had no matching `off`.
- 2026-05-26: Ran a preload IPC heuristic. `src/preload/index.ts` and `src/preload/runtime-environment-subscriptions.ts` had apparent cleanup for their `ipcRenderer.on` subscriptions.
- 2026-05-26: Ran an `ipcMain.on` cleanup heuristic over `src/main`. The only unpaired hit was `src/main/ipc/session.ts`, which is a process-lifetime registration via `registerSessionHandlers`.
- 2026-05-26: Manually reviewed main-process interval/service candidates. Confirmed `src/main/speech/stt-service.ts` retained a timed-out, terminated speech worker in service state until idle teardown.
- 2026-05-26: Ran a Promise/EventEmitter heuristic over main, relay, and CLI code. Confirmed `src/main/ssh/ssh-connection.ts` left ssh2 startup listeners attached after a successful long-lived connection.
- 2026-05-26: Confirmed `src/main/browser/cdp-ws-proxy.ts` retained its startup HTTP server `error` listener after successful bind.
- 2026-05-26: Continued Promise/EventEmitter follow-up in daemon startup paths. Confirmed startup listeners were retained after successful bind/readiness in `src/main/daemon/daemon-server.ts`, `src/main/daemon/daemon-init.ts`, and `src/main/daemon/production-launcher.ts`.
- 2026-05-26: Reviewed BrowserWindow/session listeners. Confirmed `src/main/window/attach-main-window-services.ts` stacked a persistent browser-session `will-download` listener on every main-window services attach.
- 2026-05-26: Reviewed browser-session profile lifecycle. Confirmed deleted isolated/imported profiles left partition policy callbacks/listeners and configured-partition bookkeeping behind in `src/main/browser/browser-session-registry.ts`.
- 2026-05-26: Reviewed mobile RPC subscriptions. Confirmed `mobile/src/notifications/mobile-notifications.ts` retained a local streaming callback when notification subscription cleanup ran before the desktop returned `ready`.
- 2026-05-26: Reviewed speech model extraction timers. Confirmed `src/main/speech/model-manager.ts` left the abort polling interval alive when extraction abort/timeout rejected before the child emitted `close` or `error`.
- 2026-05-26: Manually followed up renderer high-risk surfaces from the broad scan: pane manager, browser webviews, terminal pane lifecycle, editor integrations, sidebar/global document listeners, observers, and shared clocks. Remaining hits had cleanup, were process-lifetime globals, or were element-owned listeners that are collected with their DOM nodes.
- 2026-05-26: Manually followed up main-process long-lived services from the broad scan: runtime RPC, SSH services, speech worker/model manager, rate-limit polling, filesystem watchers, BrowserWindow-scoped IPC/listeners, browser-session handlers, daemon startup, and relay-backed flows.
- 2026-05-26: Checked mobile React Native subscriptions, WebView injected listeners, RPC subscription teardown, repeated polling intervals, and app/session screens. Confirmed one notifications subscription cleanup issue and no additional unpaired repeated-mount listener leaks.
- 2026-05-26: Checked `src/shared`, `src/relay`, `src/cli`, `config/scripts`, `tests/e2e`, `mobile/packages`, and native computer-use runtimes for listener/timer/socket/watch patterns. Follow-up found existing cleanup or process/test-lifetime ownership.
## Confirmed Fixes
- `src/renderer/src/components/editor/PdfViewer.tsx`: unregister the pdf.js `scalechanging` listener on cleanup to avoid retaining stale component state after repeated PDF opens.
- `src/main/speech/stt-service.ts`: when dictation stop times out, remove worker listeners, terminate the worker, and clear the warm-worker slot so later dictation cannot retain or reuse a dead worker.
- `src/main/ssh/ssh-connection.ts`: remove ssh2 startup `ready`/`error` listeners once the connection resolves or rejects; steady-state disconnect listeners remain scoped to the live connection.
- `src/main/browser/cdp-ws-proxy.ts`: remove the startup server `error` listener after the proxy binds so the resolved start promise closure is not retained for the proxy lifetime.
- `src/main/daemon/daemon-server.ts`: remove the startup server `error` listener after the daemon socket binds.
- `src/main/daemon/daemon-init.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure.
- `src/main/daemon/production-launcher.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure.
- `src/main/window/attach-main-window-services.ts`: replace the named persistent browser-session `will-download` handler before registering it so window re-attach cannot stack handlers.
- `src/main/browser/browser-session-registry.ts`: clear deleted profile partition permission/display-media callbacks, remove the download listener, and drop configured-partition bookkeeping.
- `mobile/src/notifications/mobile-notifications.ts`: always drop the local notification stream on cleanup, even if the desktop has not returned a subscription id yet.
- `src/main/speech/model-manager.ts`: clear extraction timeout/abort pollers and remove child listeners directly on abort/timeout rejection, instead of depending on a later child `close`.
## Validation
- Passed: `pnpm vitest run --config config/vitest.config.ts src/main/speech/stt-service.test.ts src/main/speech/model-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/browser/cdp-ws-proxy.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts src/main/daemon/daemon-init.test.ts src/main/window/attach-main-window-services.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-registry.persistence.test.ts` (10 files, 128 tests).
- Passed: `pnpm run typecheck:node`.
- Passed: `pnpm run typecheck:web`.
- Passed: focused `pnpm exec oxlint` over changed TypeScript files.
- Passed after final import cleanup: `pnpm vitest run --config config/vitest.config.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts`.
- Passed: `git diff --check`.
- Attempted: `./node_modules/.bin/vitest run mobile/src/notifications/mobile-notifications.test.ts`; blocked in this workspace because mobile dependencies are not installed (`expo/tsconfig.base` cannot be resolved). The mobile test file is included for the mobile package test environment.
## Residual Risk
- This was a repository-wide static/manual memory-leak audit of listener, timer, observer, worker, socket, watcher, subscription, and resource-pool patterns. It does not replace heap-snapshot profiling under a long interactive session, but no further confirmed leaks were found in the scanned patterns.
+131
View File
@@ -0,0 +1,131 @@
# Show Full Tab Title Tooltip
## Problem
Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus.
- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip.
- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip.
- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip.
- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`.
- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one.
## Root Cause
The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it.
## Non-Goals
- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics.
- Do not add persistence, IPC, or main-process behavior.
- Do not replace the tab bar layout or introduce a new tooltip primitive.
- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful.
## Design
1. Add full-title tooltips to non-editing tabs in the existing tab components.
- Terminal: display `tab.customTitle ?? tab.title`.
- Browser: display `getBrowserTabLabel(tab)`.
- Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label.
2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`.
- Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide.
- In non-rename states, wrap the sortable tab root with `<Tooltip><TooltipTrigger asChild>...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip.
- Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu.
- Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab.
- Render `<TooltipContent side="bottom" sideOffset={6} className="max-w-80 whitespace-normal break-words text-left">full label</TooltipContent>`.
- `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed.
- Keep tooltip content non-interactive; it is only a label.
3. Keep edit states out of the tooltip path.
- Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip.
- Double-click handlers for rename/pin must keep their current event behavior.
4. Preserve drag, activation, and close controls.
- `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths.
- Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking.
- The outer tab remains the sortable/activation target.
5. Add focused regression coverage for the render contract.
- Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label.
- Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes.
## Data Flow
- Store/runtime tab state updates title fields.
- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`.
- Each tab component computes the displayed label.
- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path.
- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay.
- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path.
## Edge Cases
- Terminal custom title overrides the runtime title in both visible label and tooltip.
- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props.
- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label.
- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab.
- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges.
- Rename modes must not show stale title tooltips over the input.
- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior.
- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged.
- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes.
- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs.
- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use.
- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted.
## Test Plan
- Unit: add or update tab-bar component tests that mock tooltip primitives and assert:
- terminal tab label tooltip content uses custom title when present;
- browser tab label tooltip content uses `getBrowserTabLabel`;
- editor tab label tooltip content uses `getEditorDisplayLabel`;
- the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability;
- rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state.
- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works.
- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label.
- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed.
- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- <test-file>` if focused files are added.
## UI Quality Bar
- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily.
- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography.
- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport.
- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift.
- Light and dark theme surfaces remain legible.
## Review Screenshots
1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip.
2. Browser tab with a long page title or URL hovered, showing the full title tooltip.
3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged.
4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring.
## Rollout
1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`.
2. Add focused tests for tooltip label wiring.
3. Run relevant tests, typecheck, and lint.
4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls.
## Lightweight Eng Review
- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue.
- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior.
- Failure modes covered:
- Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes.
- Rename mode could show stale tooltip content over an input if the tooltip is not gated out.
- Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it.
- Long tooltip text could overflow the viewport if content width is unbounded.
- Browser fallback labels could drift if tooltip recomputes a different label than the visible span.
- Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row.
- Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy.
- Test coverage required:
- Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components.
- Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability.
- Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels.
- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement.
- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark.
- Required review screenshots:
1. Long terminal tab tooltip.
2. Long browser tab tooltip.
3. Long editor tab tooltip with existing adornments intact.
4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state.
- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display.
@@ -0,0 +1,243 @@
# SSH Handler Re-registration Port Forwards
## Problem
Issue #2932 reported that macOS window reactivation can re-run
`attachMainWindowServices`, which calls `registerSshHandlers` again
([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)).
Before this change, `registerSshHandlers` removed and re-added IPC handlers but
also replaced the module-level `connectionManager` and `portForwardManager`
([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438),
[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)).
`activeSessions` remains module-global
([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)),
so live relay sessions kept references to the old port-forward manager while
new IPC handlers read a fresh empty one.
The visible failure is:
1. Connect an SSH target.
2. Add a local port forward.
3. Close all windows on macOS while the app process remains alive.
4. Reactivate Orca, causing SSH handlers to register again.
5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot
remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can
fail because the fresh connection manager has no live connection, and
`ssh:disconnect` does not close the old SSH connection or local listener.
Re-adding the same local port fails because the old server remains bound.
## Root Cause
SSH handler registration mixes two lifetimes:
- Process-lifetime session state: active SSH connections, relay sessions, port
listeners, relay lost backoff, reset/connect in-flight maps.
- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers.
The previous re-registration path preserved `activeSessions` but replaced the
managers that sessions and IPC handlers must share. Port-forward IPC operations
use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992),
[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047),
[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)),
and disconnect/terminate cleanup also uses that variable
([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750),
[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)).
After replacement, those operations no longer targeted the manager that owns the
live local servers. Replacing `connectionManager` also strands the live
`SshConnection` objects: existing relay sessions still hold their current
connection, but new IPC handlers and `getSshConnectionManager()` see an empty
manager.
## Non-goals
- Do not change relay protocol, remote deployment, or SSH transport behavior.
- Do not redesign port-forward persistence or enrichment.
- Do not change renderer UI.
- Do not introduce a second SSH service layer.
- Do not force-dispose live SSH sessions merely because a window was recreated.
## Design
1. Preserve process-lifetime managers across handler re-registration.
Instantiate `SshConnectionManager` and `SshPortForwardManager` only when
absent; later `registerSshHandlers` calls reuse the existing instances.
2. Refresh every live callback owner on re-registration. This is required; a
plain `connectionManager ??= new SshConnectionManager(callbacks)` is not
enough.
- `SshConnectionManager` must update callbacks used by both future and
existing `SshConnection` objects, either via explicit `setCallbacks` methods
on manager/connection or via a stable callback proxy whose implementation
is mutable.
- Existing `SshRelaySession` objects must refresh `getMainWindow`, store,
runtime, and detected-port callback references. Event handlers must call
the current callback at event time; do not capture the old `getMainWindow`
in long-lived provider callbacks.
- The credential-request tracking set must not be per-registration if live
connections can switch callbacks during an in-flight `ssh:connect`.
3. Re-register IPC handlers and dependent global listeners on every call.
`ipcMain` handlers, advertised URL refresh, credential IPC, browse handler,
and power-monitor listeners are window-registration concerns and should still
point at the latest window.
4. Preserve existing explicit teardown behavior.
`ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and
double-connect cleanup must still remove forwards through the shared manager
before detaching or disposing sessions.
5. Add regression tests in `src/main/ipc/ssh.test.ts`.
Connect a target, add a mocked port forward, call `registerSshHandlers`
again, then assert:
- `ssh:listPortForwards` still returns the original forward.
- `ssh:removePortForward` can remove the original id.
- `ssh:addPortForward`/`ssh:updatePortForward` still use the original live
connection.
- A second re-registration followed by `ssh:disconnect` still calls
`removeAllForwards` and `disconnect` on the original shared managers.
- State, credential, PTY, and detected-port callbacks from an existing live
session publish to the newest window after re-registration.
## Data Flow
- First registration:
- `registerSshHandlers(store, getWindowA)` creates store wrapper, connection
manager, port-forward manager, handlers, listeners, and current callback
environment.
- `ssh:connect` creates a relay session with the shared port-forward manager.
- `ssh:addPortForward` stores a local server in that same manager.
- Window reactivation:
- `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers.
- Existing managers are reused.
- Existing connection and relay-session callback owners are refreshed to the
latest store/runtime/window environment.
- New handlers close over `getWindowB` and call the same managers.
- Cleanup:
- `ssh:removePortForward` and `ssh:disconnect` operate on the same manager
that owns the live forward, then broadcast through the latest window.
## Edge Cases
- Re-registration while a target is connected and has active port forwards.
- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect,
or disconnect is in flight. The operation must not split credential tracking
or create a session that holds stale callbacks.
- Re-registration while no targets are connected.
- Re-registration after the store object changes. Either update existing relay
sessions to use the new store/runtime or document and test the stronger
invariant that production re-registration always passes the same process
store/runtime.
- Re-registration after the window changes. All broadcasts, credential prompts,
PTY events, detected-port events, advertised URL refreshes, relay-loss state
changes, and terminal relay errors must use the newest `getMainWindow`.
- Disconnect after re-registration must release old local ports.
- `ssh:connect` after window reactivation must be idempotent when the existing
session is already ready and healthy: return the connected state without
tearing down forwards. Explicit reset/reconnect or non-ready replacement paths
must still await old port teardown before restoring forwards.
- `getSshConnectionManager()` consumers must continue to see live connections
after re-registration.
- Test isolation must not depend on module-singleton state leaking between
tests. Add explicit reset/teardown support if preserving managers makes
`beforeEach(registerSshHandlers)` insufficient.
- SSH and relay paths must keep working for remote targets; the fix must not
assume local filesystem or local-only execution.
- Windows/Linux remain unaffected: re-registration can still happen during
development or future window lifecycles, and the fix must avoid path or
platform assumptions.
## Test Plan
- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts`
- Add regression coverage for list/remove/disconnect after handler
re-registration.
- Add coverage that add/update after re-registration uses the still-live
connection manager connection, not a fresh empty manager.
- Add coverage that existing connection/session callbacks publish to a second
mock window after re-registration.
- Add an in-flight connect or credential-request test if callback refresh uses
mutable callback objects.
- Existing connect, disconnect, reset, relay-loss, and terminate tests cover
adjacent lifecycle behavior.
- Typecheck: `pnpm typecheck`.
- Lint: `pnpm lint`.
- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if
available in the running app, add a disposable local port forward, trigger
window/service re-registration by closing and reopening the main window on
macOS, then verify the forward remains listed and removable. IPC/unit tests
are supporting evidence only; if the golden path cannot be exercised safely,
halt before PR and report the missing evidence.
## UI Quality Bar
Not UI-visible. No layout, copy, or visual styling changes are expected. The
only user-visible expectation is that existing SSH port-forward rows remain
present and actionable after window reactivation.
## Review Screenshots
1. SSH target connected with a port forward listed before re-registration.
2. Same SSH target after window reactivation, showing the same port forward
still listed.
3. Same SSH target after removing the port forward, showing it gone without an
error.
## Rollout
1. Add the focused regression test to prove the current lifecycle bug.
2. Change SSH handler registration to reuse process-lifetime managers.
3. Run the focused test, then typecheck and lint.
4. Validate in Electron against an SSH target if feasible; otherwise halt
before PR if the golden-path SSH UI cannot be exercised.
## Lightweight Eng Review
- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence
redesign is needed because the broken boundary is manager replacement during
handler re-registration.
- Architecture/data flow: process-lifetime managers stay module-level and are
reused; window-lifetime IPC handlers/listeners are refreshed; existing
connection and relay-session callback owners must also be refreshed or proxied
so live events target the current BrowserWindow.
- Failure modes covered:
- Active forwards becoming invisible after re-registration.
- `ssh:removePortForward` missing the old forward id.
- `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty
connection manager.
- `ssh:disconnect` failing to close old SSH connections and local listeners
after re-registration.
- Store/window/runtime callback refresh after re-registration.
- Re-registration during in-flight connect/reset/reconnect.
- No-session re-registration continuing to work.
- Test coverage required:
- Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across
`registerSshHandlers` calls.
- Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after
re-registration.
- Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the
newest window after re-registration.
- Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test
teardown/reset of module singletons.
- Existing lifecycle tests for reset, terminate, relay loss, and sleep remain
adjacent coverage.
- Performance/blast radius: no material startup or IPC cost. Reusing managers
avoids leaked runtime state and does not add polling, watchers, or
cross-process calls. Callback refresh is O(number of live SSH connections and
sessions) per registration, which should be tiny.
- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state
rather than changing layout or copy.
- Required review screenshots:
1. Connected SSH target with active port forward before re-registration.
2. Connected SSH target with same port forward after re-registration.
3. Connected SSH target after removing that forward.
- Feasibility: one-time manager creation is feasible only with callback refresh
for existing `SshConnection` and `SshRelaySession` instances. If that refresh
proves larger than expected, prefer a stable callback proxy over recreating
managers; do not dispose live sessions just to make callback ownership easier.
- Residual risks: Electron validation may be constrained by availability of an
existing SSH target and by avoiding live-user port collisions. If the golden
path cannot be exercised safely, stop before opening a PR and report the
missing manual evidence.
+88
View File
@@ -0,0 +1,88 @@
# Terminal Main-Owned State
## Problem
Hidden and background terminal panes cannot rely on renderer memory as the only
place that terminal output exists. Chromium may throttle a hidden Electron
document while the PTY continues producing bytes. If the renderer retains every
hidden byte until xterm can parse it, a noisy terminal can pin large strings in
renderer memory and stall or crash the app.
The renderer must keep a hard memory bound, but the user-visible terminal state
should still be recoverable when the pane becomes visible again.
## Reference Pattern
Use a host-owned terminal model as the recovery source and treat the renderer as
a view:
- The host process receives PTY bytes first and appends them to a bounded
headless terminal model.
- The renderer writes visible output directly for low latency.
- Hidden renderer queues are bounded. When they overflow, the renderer marks its
xterm as stale and drops further hidden bytes instead of retaining them.
- On visibility resume, the renderer asks the host for a serialized snapshot,
clears its xterm, replays that snapshot, then resumes live writes.
- Live output racing with restore carries a monotonic sequence number, so bytes
already included in the snapshot are not written twice.
This gives the foreground path the same latency profile as today, bounds hidden
renderer memory, and preserves terminal state from the host-owned model instead
of depending on an unbounded renderer backlog.
## Requirements
- Renderer hidden-output memory is capped per terminal.
- A hidden flood must not grow the renderer by retaining strings or chunk arrays
past the cap.
- Restoring a stale renderer must use main/runtime state for local, daemon, and
SSH PTYs.
- Remote runtime PTYs that do not have local main-owned state must keep the
existing warning fallback rather than pretending recovery is available.
- Restore must avoid xterm query auto-replies reaching the shell.
- Clear, resize, exit, and pane disposal must clean up recovery state.
- The SSH path must participate in the same sequencing and snapshot behavior as
local PTYs.
## Chosen Design
The existing runtime headless terminal is the main-owned model. Every PTY byte
already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for
local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated
and can serialize it.
The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded:
1. The scheduler replaces the queued backlog with a small warning fallback.
2. The terminal connection marks that pane as needing main-state recovery.
3. Further hidden bytes for that stale pane are not enqueued in the renderer.
4. When the pane/document becomes visible, the connection requests a main-owned
snapshot, clears xterm, replays the snapshot under the replay guard, and
sends the normal post-reattach reset.
5. Live foreground chunks that arrive while restore is in flight are retained in
a small bounded queue. After snapshot replay, sequence numbers decide which
chunks were already included and which still need to be written.
If the main snapshot is unavailable, the small warning fallback remains the
visible behavior. That is the expected fallback for surfaces without local
main-owned terminal state.
## Non-Goals
- This does not add a durable full byte log. The host-owned model is bounded
terminal state, not an infinite transcript.
- This does not throttle the PTY producer. Producer backpressure can be added
later with ACKs if we need to reduce host-side work during extreme floods.
- This does not change foreground terminal write latency.
## Verification
- Unit-test scheduler overflow callback behavior and chunk-count bounding.
- Unit-test renderer recovery so hidden overflow drops renderer backlog, fetches
the main snapshot on visibility/foreground resume, and does not duplicate
sequenced live output.
- Unit-test main IPC snapshot sequencing.
- Run terminal scheduler and PTY connection tests.
- Run typecheck.
- Exercise the Electron hidden-flood repro and confirm renderer memory stays
bounded while the recovered terminal shows the host-owned terminal state.
+6 -1
View File
@@ -48,7 +48,12 @@ export default defineConfig({
index: resolve('src/main/index.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts')
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
}
}
},
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.9",
"version": "0.0.10",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -69,7 +69,7 @@
"expo-camera",
{
"cameraPermission": "Allow Orca to use the camera for QR code scanning.",
"microphonePermission": false,
"microphonePermission": "Allow Orca to use your microphone for voice dictation sent to your paired desktop for transcription.",
"recordAudioAndroid": false
}
],
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@
"expo-secure-store": "^55.0.13",
"expo-splash-screen": "^55.0.20",
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"react": "^19.2.6",
"react-native": "^0.83.9",
+43
View File
@@ -59,6 +59,9 @@ importers:
expo-status-bar:
specifier: ^55.0.6
version: 55.0.6(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-react-native:
specifier: ^1.14.0
version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -2162,6 +2165,9 @@ packages:
'@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -2205,6 +2211,9 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2904,6 +2913,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -2919,6 +2932,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -3699,6 +3715,10 @@ packages:
hermes-parser@0.35.0:
resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -4310,6 +4330,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -8363,6 +8386,10 @@ snapshots:
'@types/hammerjs@2.0.46': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -8421,6 +8448,8 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
'@types/unist@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.6.0
@@ -9257,6 +9286,8 @@ snapshots:
depd@2.0.0: {}
dequal@2.0.3: {}
destroy@1.2.0: {}
detect-libc@2.1.2: {}
@@ -9265,6 +9296,10 @@ snapshots:
detect-node-es@1.1.0: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
diff-sequences@29.6.3: {}
dnssd-advertise@1.1.4: {}
@@ -10281,6 +10316,8 @@ snapshots:
dependencies:
hermes-estree: 0.35.0
highlight.js@11.11.1: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -11117,6 +11154,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@10.4.3: {}
lru-cache@11.3.6: {}
+19 -1
View File
@@ -1,6 +1,6 @@
import { Image, StyleSheet, Text, View } from 'react-native'
import { Terminal } from 'lucide-react-native'
import Svg, { G, Path } from 'react-native-svg'
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
@@ -21,6 +21,23 @@ function PiIcon({ size = 16 }: { size?: number }) {
)
}
function OmpIcon({ size = 16 }: { size?: number }) {
// SVG sourced from omp.sh's transparent homepage mark. Why: react-native-svg
// does not support the homepage's CSS oklch stops, so use its favicon hex stops.
return (
<Svg width={size} height={size} viewBox="0 0 64 64">
<Defs>
<LinearGradient id="ompMarkGradient" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor="#ed4abf" />
<Stop offset=".5" stopColor="#9b4dff" />
<Stop offset="1" stopColor="#5ad8e6" />
</LinearGradient>
</Defs>
<Path fill="url(#ompMarkGradient)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
)
}
function AiderIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 436 436">
@@ -65,6 +82,7 @@ export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?
if (agentId === 'claude') return <ClaudeIcon size={size} />
if (agentId === 'codex') return <OpenAIIcon size={size} />
if (agentId === 'pi') return <PiIcon size={size} />
if (agentId === 'omp') return <OmpIcon size={size} />
if (agentId === 'aider') return <AiderIcon size={size} />
if (agentId === '__blank__' || agentId === 'blank') {
return <Terminal size={size} color={colors.textMuted} />
@@ -0,0 +1,287 @@
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
Bold,
Code2,
FileCode2,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
Link,
List,
ListOrdered,
ListTodo,
Pilcrow,
Quote,
Strikethrough
} from 'lucide-react-native'
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
import { colors, radii, spacing } from '../theme/mobile-theme'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
} from './mobile-rich-markdown-editor-html'
const EDITOR_DOCUMENT_ORIGIN = 'https://orca-mobile-editor.invalid'
const EDITOR_DOCUMENT_URL = `${EDITOR_DOCUMENT_ORIGIN}/rich-markdown-editor`
function normalizeExternalEditorUrl(value: string): string | null {
const url = value.trim()
if (!url) return null
for (let index = 0; index < url.length; index += 1) {
const code = url.charCodeAt(index)
if (code <= 32 || code === 127) return null
}
if (/^mailto:/i.test(url)) return url
if (!/^https?:\/\//i.test(url)) return null
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null
} catch {
return null
}
}
type RichMarkdownCommand =
| 'paragraph'
| 'heading1'
| 'heading2'
| 'heading3'
| 'bold'
| 'italic'
| 'strike'
| 'bulletList'
| 'orderedList'
| 'taskList'
| 'quote'
| 'inlineCode'
| 'codeBlock'
| 'link'
| 'image'
type Props = {
content: string
editable: boolean
onChange: (content: string) => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
| { type: 'openLink'; url: string }
type ToolbarItem = {
command: RichMarkdownCommand
label: string
icon: ComponentType<{ size?: number; color?: string }>
}
const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'paragraph', label: 'Body', icon: Pilcrow },
{ command: 'heading1', label: 'H1', icon: Heading1 },
{ command: 'heading2', label: 'H2', icon: Heading2 },
{ command: 'heading3', label: 'H3', icon: Heading3 },
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'strike', label: 'Strike', icon: Strikethrough },
{ command: 'bulletList', label: 'Bullet list', icon: List },
{ command: 'orderedList', label: 'Numbered list', icon: ListOrdered },
{ command: 'taskList', label: 'Checklist', icon: ListTodo },
{ command: 'quote', label: 'Quote', icon: Quote },
{ command: 'link', label: 'Link', icon: Link },
{ command: 'image', label: 'Image', icon: ImageIcon },
{ command: 'inlineCode', label: 'Inline code', icon: Code2 },
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
const webViewRef = useRef<WebView>(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
const currentWebViewContentRef = useRef<string | null>(null)
const html = useMemo(() => buildMobileRichMarkdownEditorHtml(), [])
const inject = useCallback((script: string) => {
webViewRef.current?.injectJavaScript(`${script}\ntrue;`)
}, [])
const applyContent = useCallback(
(nextContent: string) => {
documentGenerationRef.current += 1
currentWebViewContentRef.current = nextContent
inject(
`window.__orcaRichMarkdown && window.__orcaRichMarkdown.setMarkdown(${escapeInjectedJavaScriptString(nextContent)}, ${documentGenerationRef.current});`
)
},
[inject]
)
const applyEditable = useCallback(
(nextEditable: boolean) => {
inject(
`window.__orcaRichMarkdown && window.__orcaRichMarkdown.setEditable(${nextEditable ? 'true' : 'false'});`
)
},
[inject]
)
useEffect(() => {
if (!readyRef.current) return
if (currentWebViewContentRef.current !== content) {
applyContent(content)
}
}, [applyContent, content])
useEffect(() => {
if (readyRef.current) {
applyEditable(editable)
}
}, [applyEditable, editable])
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: unknown
try {
message = JSON.parse(event.nativeEvent.data)
} catch {
return
}
if (!message || typeof message !== 'object') return
const editorMessage = message as Partial<EditorWebViewMessage>
if ('type' in message && message.type === 'ready') {
readyRef.current = true
applyContent(content)
applyEditable(editable)
return
}
if (
editorMessage.type === 'change' &&
typeof editorMessage.markdown === 'string' &&
editorMessage.generation === documentGenerationRef.current
) {
currentWebViewContentRef.current = editorMessage.markdown
onChange(editorMessage.markdown)
return
}
if (editorMessage.type === 'openLink' && typeof editorMessage.url === 'string') {
const url = normalizeExternalEditorUrl(editorMessage.url)
if (url) {
void Linking.openURL(url).catch(() => {})
}
}
},
[applyContent, applyEditable, content, editable, onChange]
)
const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => {
const url = request.url ?? ''
const isEditorDocument =
url === 'about:blank' ||
url === EDITOR_DOCUMENT_URL ||
url.startsWith(`${EDITOR_DOCUMENT_URL}#`)
// Why: editor content is untrusted markdown; links must leave through openLink.
return isEditorDocument
}, [])
const runCommand = useCallback(
(command: RichMarkdownCommand) => {
inject(
`window.__orcaRichMarkdown && window.__orcaRichMarkdown.runCommand(${escapeInjectedJavaScriptString(command)});`
)
},
[inject]
)
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.toolbarContent}
keyboardShouldPersistTaps="handled"
>
{TOOLBAR_ITEMS.map((item) => {
const Icon = item.icon
return (
<Pressable
key={item.command}
disabled={!editable}
accessibilityRole="button"
accessibilityLabel={item.label}
onPress={() => runCommand(item.command)}
style={({ pressed }) => [
styles.toolbarButton,
pressed && editable ? styles.toolbarButtonPressed : null,
!editable ? styles.toolbarButtonDisabled : null
]}
>
<Icon size={15} color={editable ? colors.textPrimary : colors.textMuted} />
</Pressable>
)
})}
</ScrollView>
</View>
<WebView
ref={webViewRef}
source={{ html, baseUrl: EDITOR_DOCUMENT_URL }}
originWhitelist={[EDITOR_DOCUMENT_ORIGIN, 'about:blank']}
javaScriptEnabled
domStorageEnabled={false}
hideKeyboardAccessoryView
keyboardDisplayRequiresUserAction={false}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
style={styles.webView}
scrollEnabled
bounces={false}
nestedScrollEnabled
setSupportMultipleWindows={false}
automaticallyAdjustContentInsets={false}
/>
</View>
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
const styles = StyleSheet.create({
container: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 42,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
toolbarContent: {
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.sm,
paddingVertical: 6
},
toolbarButton: {
minWidth: 30,
height: 30,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.xs
},
toolbarButtonPressed: {
backgroundColor: colors.bgRaised
},
toolbarButtonDisabled: {
opacity: 0.55
},
webView: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
}
})
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
} from './mobile-rich-markdown-editor-html'
function editorScript(): string {
const html = buildMobileRichMarkdownEditorHtml()
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1]
expect(script).toBeTruthy()
return script ?? ''
}
function extractFunctionSource(script: string, name: string): string {
const start = script.indexOf(`function ${name}`)
expect(start).toBeGreaterThanOrEqual(0)
const bodyStart = script.indexOf('{', start)
let depth = 0
for (let index = bodyStart; index < script.length; index += 1) {
const char = script[index]
if (char === '{') depth += 1
if (char === '}') {
depth -= 1
if (depth === 0) return script.slice(start, index + 1)
}
}
throw new Error(`Could not extract ${name}`)
}
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
const script = editorScript()
const sources = [
'var editable = arguments[1];',
extractFunctionSource(script, 'decodeMarkdownEntities'),
extractFunctionSource(script, 'escapeHtml'),
extractFunctionSource(script, 'escapeAttr'),
extractFunctionSource(script, 'isSafeUrl'),
extractFunctionSource(script, 'splitTableRow'),
extractFunctionSource(script, 'isTableSeparator'),
extractFunctionSource(script, 'renderInline'),
extractFunctionSource(script, 'isBlockStart'),
extractFunctionSource(script, 'indentationWidth'),
extractFunctionSource(script, 'parseListLine'),
extractFunctionSource(script, 'listKind'),
extractFunctionSource(script, 'parseListTree'),
extractFunctionSource(script, 'renderListItems'),
extractFunctionSource(script, 'markdownToHtml'),
'return markdownToHtml(arguments[0]);'
].join('\n')
return new Function(sources)(markdown, editable) as string
}
function runtimeListMarkdown(): (list: unknown) => string {
const script = editorScript()
const sources = [
'function listItemText(li) { return li.text; }',
'function directNestedLists(li) { return li.nestedLists || []; }',
extractFunctionSource(script, 'listMarkdown'),
'return function (list) { return listMarkdown(list, 0); };'
].join('\n')
return new Function(sources)() as (list: unknown) => string
}
describe('mobile rich markdown editor HTML', () => {
it('builds parseable WebView JavaScript', () => {
const script = editorScript()
expect(() => new Function(script)).not.toThrow()
})
it('escapes injected markdown without reopening script tags', () => {
const escaped = escapeInjectedJavaScriptString('</script><script>alert(1)</script>')
expect(escaped).not.toContain('</script>')
expect(JSON.parse(escaped.replace(/<\\\/script/gi, '</script'))).toBe(
'</script><script>alert(1)</script>'
)
})
it('renders and serializes nested bullet, ordered, and task lists with indentation intact', () => {
const markdown = [
'- Parent',
' 1. Ordered child',
' - [x] Done task',
' - [ ] Open task',
'- Sibling'
].join('\n')
const html = runtimeMarkdownToHtml(markdown, true)
expect(html).toContain(
'<ul><li><p>Parent</p><ol start="1"><li value="1" data-list-number="1"><p>Ordered child</p>'
)
expect(html).toContain('<ul data-type="taskList">')
expect(html).toContain('<li><p>Sibling</p></li></ul>')
const listMarkdown = runtimeListMarkdown()
const fakeList = {
tagName: 'UL',
getAttribute: () => null,
children: [
{
tagName: 'LI',
text: 'Parent',
querySelector: () => null,
nestedLists: [
{
tagName: 'OL',
getAttribute: () => null,
children: [
{
tagName: 'LI',
text: 'Ordered child',
querySelector: () => null,
nestedLists: [
{
tagName: 'UL',
getAttribute: (name: string) => (name === 'data-type' ? 'taskList' : null),
children: [
{
tagName: 'LI',
text: 'Done task',
querySelector: () => ({ checked: true }),
nestedLists: []
},
{
tagName: 'LI',
text: 'Open task',
querySelector: () => ({ checked: false }),
nestedLists: []
}
]
}
]
}
]
}
]
},
{ tagName: 'LI', text: 'Sibling', querySelector: () => null, nestedLists: [] }
]
}
expect(listMarkdown(fakeList)).toBe(markdown)
})
it('renders markdown entities as characters without double-escaping them', () => {
const html = runtimeMarkdownToHtml('R&D &amp; Sales and &lt;tag&gt;', true)
expect(html).toContain('R&amp;D &amp; Sales and &lt;tag&gt;')
expect(html).not.toContain('&amp;amp;')
})
it('preserves explicit ordered-list numbering during serialization', () => {
const markdown = ['3. Third step', '4. Fourth step'].join('\n')
const html = runtimeMarkdownToHtml(markdown, true)
expect(html).toContain('<ol start="3">')
expect(html).toContain('data-list-number="3"')
const listMarkdown = runtimeListMarkdown()
const fakeList = {
tagName: 'OL',
getAttribute: () => null,
children: [
{
tagName: 'LI',
text: 'Third step',
getAttribute: (name: string) => (name === 'data-list-number' ? '3' : null),
querySelector: () => null,
nestedLists: []
},
{
tagName: 'LI',
text: 'Fourth step',
getAttribute: (name: string) => (name === 'data-list-number' ? '4' : null),
querySelector: () => null,
nestedLists: []
}
]
}
expect(listMarkdown(fakeList)).toBe(markdown)
})
it('serializes ordered lists from parent start when item metadata is missing', () => {
const listMarkdown = runtimeListMarkdown()
const fakeList = {
tagName: 'OL',
getAttribute: (name: string) => (name === 'start' ? '8' : null),
children: [
{
tagName: 'LI',
text: 'Pasted step',
getAttribute: () => null,
querySelector: () => null,
nestedLists: []
},
{
tagName: 'LI',
text: 'Inserted step',
getAttribute: () => null,
querySelector: () => null,
nestedLists: []
}
]
}
expect(listMarkdown(fakeList)).toBe(['8. Pasted step', '9. Inserted step'].join('\n'))
})
it('renders task checkboxes as disabled while read-only and guards mutation emitters', () => {
const html = runtimeMarkdownToHtml('- [ ] Read-only task', false)
const script = editorScript()
expect(html).toContain('type="checkbox" disabled')
expect(extractFunctionSource(script, 'emitChange')).toContain('suppressInput || !editable')
expect(extractFunctionSource(script, 'setEditable')).toContain('syncTaskCheckboxesDisabled')
})
it('carries document generations through content replacement and change messages', () => {
const script = editorScript()
const setMarkdown = extractFunctionSource(script, 'setMarkdown')
const emitChange = extractFunctionSource(script, 'emitChange')
expect(setMarkdown).toContain('window.clearTimeout(inputTimer)')
expect(setMarkdown).toContain('documentGeneration = Number(generation) || 0')
expect(emitChange).toContain('var pendingGeneration = documentGeneration')
expect(emitChange).not.toContain('window.setTimeout')
expect(emitChange).toContain('generation: pendingGeneration')
})
})
@@ -0,0 +1,684 @@
import { colors } from '../theme/mobile-theme'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
}
export function buildMobileRichMarkdownEditorHtml(): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
:root {
color-scheme: dark;
--background: ${colors.bgBase};
--editor-surface: ${colors.bgBase};
--foreground: ${colors.textPrimary};
--muted-foreground: ${colors.textSecondary};
--muted: ${colors.bgRaised};
--border: ${colors.borderSubtle};
--primary: ${colors.textPrimary};
--primary-foreground: ${colors.bgBase};
--accent-link: ${colors.accentBlue};
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%;
min-height: 100%;
margin: 0;
background: var(--editor-surface);
color: var(--foreground);
font-family: var(--font-sans);
overscroll-behavior: contain;
}
body { overflow: auto; }
#editor {
min-height: 100vh;
padding: 18px 16px 112px;
outline: none;
font-size: 14px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: anywhere;
caret-color: var(--foreground);
}
#editor[contenteditable="false"] {
opacity: 0.78;
}
#editor:empty::before,
#editor p.is-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
#editor > :first-child { margin-top: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 0.5em;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
h1 { font-size: 1.85em; font-weight: 700; }
h2 { font-size: 1.4em; }
h3 { font-size: 1.15em; }
p, ul, ol, blockquote { margin: 0.75em 0; }
ul, ol { padding-left: 1.5em; }
ul { list-style: disc; }
ol { list-style: decimal; }
li { margin: 0.15em 0; }
li > p { margin: 0; }
ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 6px;
}
ul[data-type="taskList"] > li > label {
flex-shrink: 0;
display: flex;
align-items: center;
height: 1.55em;
user-select: none;
}
ul[data-type="taskList"] input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
margin: 0;
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
border-radius: 4px;
background: transparent;
position: relative;
}
ul[data-type="taskList"] input[type="checkbox"]:checked {
background: var(--primary);
border-color: var(--primary);
}
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid var(--primary-foreground);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
ul[data-type="taskList"] input[type="checkbox"]:disabled {
opacity: 0.65;
}
ul[data-type="taskList"] > li > div {
flex: 1;
min-width: 0;
}
ul[data-type="taskList"] > li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--muted-foreground);
}
blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.95em;
}
th, td {
padding: 8px 14px;
border: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
tr:nth-child(odd) td {
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
}
code {
padding: 0.2em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
font-size: 0.88em;
font-family: var(--font-mono);
}
pre {
margin: 0.75em 0;
padding: 14px 18px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
overflow-x: auto;
line-height: 1.55;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono);
white-space: pre-wrap;
}
pre::before {
content: attr(data-language);
display: block;
min-height: 13px;
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 11px;
text-transform: uppercase;
}
pre code {
padding: 0;
border-radius: 0;
background: transparent;
font-size: 0.92em;
white-space: pre-wrap;
}
hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
a {
color: var(--accent-link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
img {
display: block;
max-width: 100%;
margin: 0.75em 0;
border-radius: 8px;
}
</style>
</head>
<body>
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
<script>
(function () {
var editor = document.getElementById('editor');
var lastMarkdown = '';
var inputTimer = null;
var documentGeneration = 0;
var editable = true;
var suppressInput = false;
function post(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
function decodeMarkdownEntities(value) {
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
var lower = String(entity).toLowerCase();
if (lower === 'amp') return '&';
if (lower === 'lt') return '<';
if (lower === 'gt') return '>';
if (lower === 'quot') return '"';
if (lower === 'apos') return "'";
if (lower.indexOf('#x') === 0) {
var hex = Number.parseInt(lower.slice(2), 16);
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
}
if (lower.indexOf('#') === 0) {
var code = Number.parseInt(lower.slice(1), 10);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return match;
});
}
function escapeHtml(value) {
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/\\n/g, ' ');
}
function isSafeUrl(value) {
var trimmed = String(value || '').trim();
return !/^javascript:/i.test(trimmed);
}
function splitTableRow(line) {
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
return cell.trim();
});
}
function isTableSeparator(line) {
var cells = splitTableRow(line);
return cells.length > 0 && cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderInline(text) {
var output = '';
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
var lastIndex = 0;
var match;
while ((match = pattern.exec(text))) {
output += escapeHtml(text.slice(lastIndex, match.index));
var token = match[0];
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
if (image && isSafeUrl(image[2])) {
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
} else if (link && isSafeUrl(link[2])) {
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
} else if (/^https?:\\/\\//i.test(token)) {
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
} else if (token.indexOf('\`') === 0) {
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
} else if (token.indexOf('~~') === 0) {
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
} else {
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
}
lastIndex = pattern.lastIndex;
}
output += escapeHtml(text.slice(lastIndex));
return output;
}
function isBlockStart(line) {
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
}
function indentationWidth(value) {
return String(value || '').replace(/\\t/g, ' ').length;
}
function parseListLine(line) {
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
if (!match) return null;
var rawText = match[3] || '';
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
return {
indent: indentationWidth(match[1] || ''),
ordered: /^\\d/.test(match[2] || ''),
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
task: task ? task[1].toLowerCase() === 'x' : null,
text: task ? task[2] : rawText,
children: []
};
}
function listKind(item) {
if (item.task !== null) return 'task';
return item.ordered ? 'ol' : 'ul';
}
function parseListTree(lines, startIndex) {
var root = { indent: -1, children: [] };
var stack = [root];
var index = startIndex;
while (index < lines.length) {
var item = parseListLine(lines[index] || '');
if (!item) break;
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
stack.pop();
}
stack[stack.length - 1].children.push(item);
stack.push(item);
index += 1;
}
return { items: root.children, nextIndex: index };
}
function renderListItems(items) {
var html = [];
var index = 0;
while (index < items.length) {
var kind = listKind(items[index]);
var group = [];
while (index < items.length && listKind(items[index]) === kind) {
group.push(items[index]);
index += 1;
}
var tag = kind === 'ol' ? 'ol' : 'ul';
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
html.push('<' + tag + attrs + '>' + group.map(function (item) {
var children = item.children.length ? renderListItems(item.children) : '';
if (kind === 'task') {
var checked = item.task === true;
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
}
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
}).join('') + '</' + tag + '>');
}
return html.join('');
}
function markdownToHtml(markdown) {
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
var html = [];
var index = 0;
while (index < lines.length) {
var line = lines[index] || '';
if (!line.trim()) {
index += 1;
continue;
}
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
if (fence) {
index += 1;
var code = [];
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
code.push(lines[index] || '');
index += 1;
}
if (index < lines.length) index += 1;
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
continue;
}
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
html.push('<hr />');
index += 1;
continue;
}
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
var headers = splitTableRow(line);
index += 2;
var rows = [];
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
rows.push(splitTableRow(lines[index] || ''));
index += 1;
}
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>');
continue;
}
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
if (heading) {
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
index += 1;
continue;
}
if (/^>\\s?/.test(line)) {
var quote = [];
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
index += 1;
}
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
continue;
}
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
var list = parseListTree(lines, index);
html.push(renderListItems(list.items));
index = list.nextIndex;
continue;
}
var paragraph = [];
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
paragraph.push(lines[index] || '');
index += 1;
}
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
}
return html.join('\\n') || '<p class="is-empty"><br /></p>';
}
function textContent(node) {
return (node.textContent || '').replace(/\\u00a0/g, ' ');
}
function inlineMarkdown(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag === 'br') return '\\n';
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';
if (tag === 'label') return '';
return inlineChildren(el);
}
function inlineChildren(el) {
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
}
function listItemText(li) {
var clone = li.cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
return inlineChildren(clone).trim();
}
function directNestedLists(li) {
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
return list.closest('li') === li;
});
}
function listMarkdown(el, depth) {
var tag = el.tagName.toLowerCase();
var isTask = el.getAttribute('data-type') === 'taskList';
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
if (!Number.isFinite(orderedStart)) orderedStart = 1;
var indent = ' '.repeat(depth);
return Array.prototype.map.call(el.children, function (li, index) {
if (li.tagName.toLowerCase() !== 'li') return '';
var marker;
if (isTask) {
var input = li.querySelector('input[type="checkbox"]');
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
} else {
var listNumber =
li.getAttribute &&
(li.getAttribute('data-list-number') || li.getAttribute('value'));
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
}
var line = indent + marker + listItemText(li);
var nested = directNestedLists(li).map(function (list) {
return listMarkdown(list, depth + 1);
}).filter(Boolean).join('\\n');
return nested ? line + '\\n' + nested : line;
}).filter(Boolean).join('\\n');
}
function blockMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
if (tag === 'blockquote') {
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
}
if (tag === 'pre') {
var lang = el.getAttribute('data-language') || '';
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
}
if (tag === 'ul' || tag === 'ol') {
return listMarkdown(el, 0);
}
if (tag === 'table') {
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
if (rows.length === 0) return '';
var cellsFor = function (row) {
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
};
var headers = cellsFor(rows[0]);
var bodyRows = rows.slice(1).map(cellsFor);
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
}
if (tag === 'hr') return '---';
if (tag === 'img') return inlineMarkdown(el);
return inlineChildren(el).trim();
}
function currentMarkdown() {
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
return block.trim().length > 0;
}).join('\\n\\n').trimEnd();
}
function syncTaskCheckboxesDisabled() {
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
input.disabled = !editable;
});
}
function emitChange() {
if (suppressInput || !editable) return;
window.clearTimeout(inputTimer);
var pendingGeneration = documentGeneration;
lastMarkdown = currentMarkdown();
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
}
function setMarkdown(markdown, generation) {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
suppressInput = false;
}
function setEditable(nextEditable) {
editable = Boolean(nextEditable);
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
syncTaskCheckboxesDisabled();
}
function focusEditor() {
editor.focus();
}
function restoreSelectionOrEnd() {
focusEditor();
var selection = window.getSelection();
if (!selection || selection.rangeCount > 0) return;
var range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
function wrapSelection(tagName) {
restoreSelectionOrEnd();
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) return;
var wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch (_error) {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
selection.removeAllRanges();
selection.selectAllChildren(wrapper);
emitChange();
}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
else if (command === 'bold') document.execCommand('bold');
else if (command === 'italic') document.execCommand('italic');
else if (command === 'strike') document.execCommand('strikeThrough');
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
else if (command === 'orderedList') document.execCommand('insertOrderedList');
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
else if (command === 'inlineCode') wrapSelection('code');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
else if (command === 'link') {
var href = window.prompt('Link URL');
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
} else if (command === 'image') {
var src = window.prompt('Image URL');
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
}
syncTaskCheckboxesDisabled();
emitChange();
}
editor.addEventListener('input', function () {
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (input) {
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
}
if (editable) emitChange();
});
editor.addEventListener('click', function (event) {
var link = event.target && event.target.closest && event.target.closest('a[href]');
if (link) {
event.preventDefault();
post({ type: 'openLink', url: link.getAttribute('href') || '' });
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) return;
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
emitChange();
});
editor.addEventListener('keydown', function (event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
event.preventDefault();
runCommand('bold');
}
});
window.__orcaRichMarkdown = {
setMarkdown: setMarkdown,
setEditable: setEditable,
runCommand: runCommand,
currentMarkdown: currentMarkdown
};
post({ type: 'ready' });
})();
</script>
</body>
</html>`
}
+3
View File
@@ -1,3 +1,6 @@
/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording,
* chunk upload, completion, and cancellation in one hook so native audio state
* cannot drift from the runtime RPC lifecycle. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { Buffer } from 'buffer'
import {
@@ -0,0 +1,91 @@
function extname(filePath: string): string {
const lastDot = filePath.lastIndexOf('.')
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
if (lastDot <= lastSlash) {
return ''
}
return filePath.slice(lastDot)
}
const EXT_TO_LANGUAGE: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.json': 'json',
'.jsonc': 'json',
'.md': 'markdown',
'.mdx': 'markdown',
'.css': 'css',
'.scss': 'scss',
'.less': 'less',
'.html': 'html',
'.htm': 'html',
'.xml': 'xml',
'.svg': 'xml',
'.py': 'python',
'.rs': 'rust',
'.go': 'go',
'.java': 'java',
'.kt': 'kotlin',
'.kts': 'kotlin',
'.c': 'c',
'.h': 'c',
'.cpp': 'cpp',
'.cc': 'cpp',
'.cxx': 'cpp',
'.hpp': 'cpp',
'.cs': 'csharp',
'.rb': 'ruby',
'.php': 'php',
'.swift': 'swift',
'.sh': 'shell',
'.bash': 'shell',
'.zsh': 'shell',
'.fish': 'shell',
'.ps1': 'powershell',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'ini',
'.ini': 'ini',
'.cfg': 'ini',
'.conf': 'ini',
'.sql': 'sql',
'.graphql': 'graphql',
'.gql': 'graphql',
'.lua': 'lua',
'.r': 'r',
'.R': 'r',
'.make': 'makefile'
}
const FILENAME_TO_LANGUAGE: Record<string, string> = {
Dockerfile: 'dockerfile',
Makefile: 'makefile',
'CMakeLists.txt': 'cmake',
'.gitignore': 'ini',
'.gitattributes': 'ini',
'.editorconfig': 'ini',
'.env': 'ini',
'.env.local': 'ini',
'.env.development': 'ini',
'.env.production': 'ini'
}
export function detectMobileFileLanguage(filePath: string, preferredLanguage?: string): string {
const normalizedPreferred = preferredLanguage?.trim().toLowerCase()
if (normalizedPreferred && normalizedPreferred !== 'plaintext') {
return normalizedPreferred
}
const parts = filePath.split(/[\\/]/)
const filename = parts.at(-1) ?? filePath
const exact = FILENAME_TO_LANGUAGE[filename]
if (exact) {
return exact
}
return EXT_TO_LANGUAGE[extname(filename).toLowerCase()] ?? 'plaintext'
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { detectMobileFileLanguage } from './mobile-file-language'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from './mobile-file-syntax'
import type { MobileDiffLine } from './mobile-diff-lines'
describe('mobile file syntax highlighting', () => {
it('detects common source languages from file paths', () => {
expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript')
expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell')
expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile')
expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript')
expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext')
})
it('emits semantic syntax segments for highlighted code', () => {
const result = highlightMobileCode('const label: string = "Orca"', 'typescript')
expect(result.highlighted).toBe(true)
expect(result.segments).toEqual(
expect.arrayContaining([
{ text: 'const', kind: 'keyword' },
{ text: 'string', kind: 'type' },
{ text: '"Orca"', kind: 'string' }
])
)
})
it('preserves diff line state while highlighting code inside the line', () => {
const lines: MobileDiffLine[] = [
{ kind: 'delete', text: 'const oldLabel = "Old"', oldLineNumber: 4 },
{ kind: 'add', text: 'const newLabel = "New"', newLineNumber: 4 }
]
const highlighted = highlightMobileDiffLines(lines, 'typescript')
expect(highlighted[0]?.kind).toBe('delete')
expect(highlighted[0]?.oldLineNumber).toBe(4)
expect(highlighted[0]?.segments).toContainEqual({ text: 'const', kind: 'keyword' })
expect(highlighted[1]?.kind).toBe('add')
expect(highlighted[1]?.newLineNumber).toBe(4)
expect(highlighted[1]?.segments).toContainEqual({ text: '"New"', kind: 'string' })
})
it('falls back to plain text when segment caps would create too many React Native nodes', () => {
const result = highlightMobileCode('const label: string = "Orca"', 'typescript', 1_000, 1)
expect(result.highlighted).toBe(false)
expect(result.segments).toEqual([{ text: 'const label: string = "Orca"', kind: 'plain' }])
})
it('caps highlighted diff lines and keeps later rows renderable as plain text', () => {
const lines: MobileDiffLine[] = Array.from({ length: 520 }, (_, index) => ({
kind: 'add',
text: `const value${index} = "mobile"`,
newLineNumber: index + 1
}))
const highlighted = highlightMobileDiffLines(lines, 'typescript')
expect(highlighted).toHaveLength(520)
expect(highlighted.filter((line) => line.highlighted)).toHaveLength(500)
expect(highlighted[519]).toEqual({
...lines[519],
highlighted: false,
segments: [{ text: lines[519]!.text, kind: 'plain' }]
})
})
it('stops attempting diff highlighting after a token-dense line exceeds the segment cap', () => {
const denseLine = Array.from(
{ length: 120 },
(_, index) => `const value${index}: string = "${index}"`
).join('; ')
const highlighted = highlightMobileDiffLines(
[
{ kind: 'add', text: denseLine, newLineNumber: 1 },
{ kind: 'add', text: 'const later = "plain"', newLineNumber: 2 }
] satisfies MobileDiffLine[],
'typescript'
)
expect(highlighted[0]?.highlighted).toBe(false)
expect(highlighted[1]).toMatchObject({
highlighted: false,
segments: [{ text: 'const later = "plain"', kind: 'plain' }]
})
})
it('builds plain diff syntax rows without changing diff metadata', () => {
const lines: MobileDiffLine[] = [{ kind: 'delete', text: 'plain', oldLineNumber: 9 }]
expect(buildPlainMobileDiffSyntaxLines(lines)).toEqual([
{
...lines[0],
highlighted: false,
segments: [{ text: 'plain', kind: 'plain' }]
}
])
})
})
+218
View File
@@ -0,0 +1,218 @@
import { common, createLowlight } from 'lowlight'
import { detectMobileFileLanguage } from './mobile-file-language'
export type MobileSyntaxTokenKind =
| 'plain'
| 'comment'
| 'keyword'
| 'string'
| 'number'
| 'type'
| 'function'
| 'variable'
| 'meta'
export type MobileSyntaxSegment = {
text: string
kind: MobileSyntaxTokenKind
}
export type MobileHighlightedDiffLine<TLine> = TLine & {
segments: MobileSyntaxSegment[]
highlighted: boolean
}
type LowlightNode = {
type: string
value?: string
properties?: {
className?: unknown
}
children?: LowlightNode[]
}
const lowlight = createLowlight(common)
const MAX_FILE_HIGHLIGHT_CHARS = 48_000
const MAX_FILE_HIGHLIGHT_SEGMENTS = 3_000
const MAX_DIFF_HIGHLIGHT_CHARS = 24_000
const MAX_DIFF_HIGHLIGHT_LINES = 500
const MAX_DIFF_HIGHLIGHT_SEGMENTS = 4_000
const MAX_DIFF_LINE_HIGHLIGHT_SEGMENTS = 96
const LANGUAGE_ALIASES: Record<string, string> = {
javascriptreact: 'javascript',
jsx: 'javascript',
typescriptreact: 'typescript',
tsx: 'typescript',
sh: 'bash',
shell: 'bash',
zsh: 'bash',
fish: 'bash',
yml: 'yaml'
}
export function resolveMobileSyntaxLanguage(filePath: string, preferredLanguage?: string): string {
const detected = detectMobileFileLanguage(filePath, preferredLanguage)
const normalized = LANGUAGE_ALIASES[detected] ?? detected
return lowlight.registered(normalized) ? normalized : 'plaintext'
}
export function highlightMobileCode(
code: string,
language: string,
maxHighlightChars = MAX_FILE_HIGHLIGHT_CHARS,
maxHighlightSegments = MAX_FILE_HIGHLIGHT_SEGMENTS
): { segments: MobileSyntaxSegment[]; highlighted: boolean } {
if (code.length === 0) {
return { segments: [{ text: '', kind: 'plain' }], highlighted: false }
}
const normalizedLanguage = LANGUAGE_ALIASES[language] ?? language
if (!lowlight.registered(normalizedLanguage) || normalizedLanguage === 'plaintext') {
return { segments: [{ text: code, kind: 'plain' }], highlighted: false }
}
const highlightLength = getHighlightBoundary(code, maxHighlightChars)
const highlightedCode = code.slice(0, highlightLength)
try {
const tree = lowlight.highlight(normalizedLanguage, highlightedCode) as LowlightNode
const segments = mergeAdjacentSegments(flattenLowlightNodes(tree.children ?? [], 'plain'))
if (segments.length > maxHighlightSegments) {
return { segments: [{ text: code, kind: 'plain' }], highlighted: false }
}
if (highlightLength < code.length) {
appendSegment(segments, { text: code.slice(highlightLength), kind: 'plain' })
}
return { segments, highlighted: true }
} catch {
return { segments: [{ text: code, kind: 'plain' }], highlighted: false }
}
}
export function highlightMobileDiffLines<TLine extends { text: string }>(
lines: TLine[],
language: string
): MobileHighlightedDiffLine<TLine>[] {
let attemptedChars = 0
let attemptedLines = 0
let highlightedSegments = 0
let exhaustedHighlightBudget = false
return lines.map((line) => {
const canHighlight =
!exhaustedHighlightBudget &&
attemptedLines < MAX_DIFF_HIGHLIGHT_LINES &&
attemptedChars + line.text.length <= MAX_DIFF_HIGHLIGHT_CHARS &&
highlightedSegments < MAX_DIFF_HIGHLIGHT_SEGMENTS
if (!canHighlight) {
return plainHighlightedLine(line)
}
attemptedLines += 1
attemptedChars += line.text.length
const result = highlightMobileCode(
line.text,
language,
Math.min(MAX_FILE_HIGHLIGHT_CHARS, 8_000),
MAX_DIFF_LINE_HIGHLIGHT_SEGMENTS
)
if (
!result.highlighted ||
highlightedSegments + result.segments.length > MAX_DIFF_HIGHLIGHT_SEGMENTS
) {
exhaustedHighlightBudget = true
return plainHighlightedLine(line)
}
highlightedSegments += result.segments.length
return { ...line, ...result }
})
}
export function buildPlainMobileDiffSyntaxLines<TLine extends { text: string }>(
lines: TLine[]
): MobileHighlightedDiffLine<TLine>[] {
return lines.map((line) => plainHighlightedLine(line))
}
function getHighlightBoundary(code: string, maxHighlightChars: number): number {
if (code.length <= maxHighlightChars) {
return code.length
}
const boundary = code.lastIndexOf('\n', maxHighlightChars)
return boundary > 0 ? boundary + 1 : maxHighlightChars
}
function flattenLowlightNodes(
nodes: LowlightNode[],
inheritedKind: MobileSyntaxTokenKind
): MobileSyntaxSegment[] {
const segments: MobileSyntaxSegment[] = []
for (const node of nodes) {
if (node.type === 'text') {
appendSegment(segments, { text: node.value ?? '', kind: inheritedKind })
continue
}
if (node.type !== 'element') {
continue
}
const kind = tokenKindForClasses(node.properties?.className) ?? inheritedKind
for (const segment of flattenLowlightNodes(node.children ?? [], kind)) {
appendSegment(segments, segment)
}
}
return segments
}
function tokenKindForClasses(className: unknown): MobileSyntaxTokenKind | null {
const classes = Array.isArray(className)
? className.filter((value): value is string => typeof value === 'string')
: typeof className === 'string'
? className.split(/\s+/)
: []
const tokens = new Set(classes.map((value) => value.replace(/^hljs-/, '')))
if (hasAny(tokens, ['comment', 'quote'])) return 'comment'
if (hasAny(tokens, ['keyword', 'selector-tag', 'tag', 'name'])) return 'keyword'
if (hasAny(tokens, ['string', 'regexp', 'symbol', 'bullet'])) return 'string'
if (hasAny(tokens, ['number', 'literal'])) return 'number'
if (hasAny(tokens, ['type', 'built_in', 'class', 'title.class'])) return 'type'
if (hasAny(tokens, ['title.function', 'function', 'title'])) return 'function'
if (hasAny(tokens, ['attr', 'attribute', 'property', 'variable', 'params'])) return 'variable'
if (hasAny(tokens, ['meta', 'doctag', 'subst', 'section'])) return 'meta'
return null
}
function hasAny(values: Set<string>, candidates: string[]): boolean {
return candidates.some((candidate) => values.has(candidate))
}
function mergeAdjacentSegments(segments: MobileSyntaxSegment[]): MobileSyntaxSegment[] {
const merged: MobileSyntaxSegment[] = []
for (const segment of segments) {
appendSegment(merged, segment)
}
return merged
}
function appendSegment(segments: MobileSyntaxSegment[], segment: MobileSyntaxSegment): void {
if (!segment.text) {
return
}
const previous = segments.at(-1)
if (previous?.kind === segment.kind) {
previous.text += segment.text
return
}
segments.push({ ...segment })
}
function plainHighlightedLine<TLine extends { text: string }>(
line: TLine
): MobileHighlightedDiffLine<TLine> {
return {
...line,
segments: [{ text: line.text, kind: 'plain' }],
highlighted: false
}
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('mobile session startup', () => {
it('auto-creates one terminal for an initially empty connected session', () => {
expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef<string | null>(null)')
expect(source).toContain('initialEmptySessionAutoCreateRef.current = null')
const autoCreateEffect = sliceBetween(
'if (\n !client ||\n !showEmptyState',
'const terminalSummary ='
)
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId')
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId')
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
type MobileTerminalSessionTab,
type TerminalRecord
} from './mobile-terminal-records'
const lightTheme = {
mode: 'light' as const,
theme: {
background: '#ffffff',
foreground: '#111111'
}
}
const darkTheme = {
mode: 'dark' as const,
theme: {
background: '#111111',
foreground: '#eeeeee'
}
}
describe('mobile terminal records', () => {
it('keeps session-tab terminal themes when terminal.list omits them', () => {
const terminalList: TerminalRecord[] = [
{ handle: 'pty-1', title: 'Terminal', isActive: true },
{ handle: 'pty-2', title: 'Logs', isActive: false }
]
const currentTerminals: TerminalRecord[] = [
{ handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true }
]
const sessionTabs: MobileTerminalSessionTab[] = [
{
type: 'terminal',
id: 'term-1::leaf-1',
title: 'Terminal',
terminal: 'pty-1',
terminalTheme: lightTheme,
isActive: true
}
]
expect(mergeTerminalListWithKnownRecords(terminalList, currentTerminals, sessionTabs)).toEqual([
{ handle: 'pty-1', title: 'Terminal', terminalTheme: lightTheme, isActive: true },
{ handle: 'pty-2', title: 'Logs', isActive: false }
])
})
it('falls back to the current terminal theme while waiting for session tabs', () => {
const terminalList: TerminalRecord[] = [{ handle: 'pty-1', title: 'Terminal', isActive: true }]
const currentTerminals: TerminalRecord[] = [
{ handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true }
]
expect(mergeTerminalListWithKnownRecords(terminalList, currentTerminals, [])).toEqual([
{ handle: 'pty-1', title: 'Terminal', terminalTheme: darkTheme, isActive: true }
])
})
it('ignores pending terminal tabs without a handle', () => {
expect(
getTerminalRecordsFromSessionTabs([
{
type: 'terminal',
id: 'pending',
title: 'Terminal',
terminal: null,
terminalTheme: lightTheme,
isActive: true
}
])
).toEqual([])
})
})
@@ -0,0 +1,104 @@
import type { MobileTerminalTheme } from '../terminal/TerminalWebView'
export type TerminalRecord = {
handle: string
title: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
export type MobileTerminalSessionTab = {
type: 'terminal'
id: string
title: string
parentTabId?: string
leafId?: string
status?: 'pending-handle' | 'ready'
terminal: string | null
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
type MobileSessionTabLike =
| MobileTerminalSessionTab
| {
type: string
title?: string
terminal?: unknown
terminalTheme?: MobileTerminalTheme
isActive?: boolean
}
export function mergeTerminalRecordsByCurrentOrder(
terminalTabs: TerminalRecord[],
currentTerminals: TerminalRecord[]
): TerminalRecord[] {
if (currentTerminals.length === 0) {
return terminalTabs
}
const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab]))
const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle))
return [
...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal),
...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle))
]
}
export function getTerminalRecordsFromSessionTabs(
tabs: readonly MobileSessionTabLike[]
): TerminalRecord[] {
return tabs.flatMap((tab): TerminalRecord[] => {
if (tab.type !== 'terminal' || typeof tab.terminal !== 'string') {
return []
}
return [
{
handle: tab.terminal,
title: tab.title || 'Terminal',
terminalTheme: tab.terminalTheme,
isActive: tab.isActive === true
}
]
})
}
export function mergeTerminalListWithKnownRecords(
terminalList: TerminalRecord[],
currentTerminals: TerminalRecord[],
sessionTabs: readonly MobileSessionTabLike[]
): TerminalRecord[] {
const currentTerminalsByHandle = new Map(
currentTerminals.map((terminal) => [terminal.handle, terminal])
)
const sessionTerminalsByHandle = new Map(
getTerminalRecordsFromSessionTabs(sessionTabs).map((terminal) => [terminal.handle, terminal])
)
return terminalList.map((terminal) => {
const sessionTerminal = sessionTerminalsByHandle.get(terminal.handle)
const currentTerminal = currentTerminalsByHandle.get(terminal.handle)
// Why: terminal.list summaries can omit the mobile theme; keep the richer
// session-tab/current record so polling cannot reset TerminalWebView.
return {
...terminal,
terminalTheme:
sessionTerminal?.terminalTheme ?? currentTerminal?.terminalTheme ?? terminal.terminalTheme
}
})
}
export function terminalRecordsEqual(
a: readonly TerminalRecord[],
b: readonly TerminalRecord[]
): boolean {
return (
a.length === b.length &&
a.every(
(terminal, index) =>
terminal.handle === b[index]?.handle &&
terminal.title === b[index]?.title &&
JSON.stringify(terminal.terminalTheme ?? null) ===
JSON.stringify(b[index]?.terminalTheme ?? null) &&
terminal.isActive === b[index]?.isActive
)
)
}
+8
View File
@@ -10,6 +10,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'copilot',
'opencode',
'pi',
'omp',
'gemini',
'antigravity',
'aider',
@@ -22,6 +23,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'autohand',
'cline',
'codebuff',
'command-code',
'continue',
'cursor',
'droid',
@@ -40,6 +42,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
pi: 'Pi',
omp: 'OMP',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
@@ -52,6 +55,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
autohand: 'Autohand Code',
cline: 'Cline',
codebuff: 'Codebuff',
'command-code': 'Command Code',
continue: 'Continue',
cursor: 'Cursor',
droid: 'Droid',
@@ -67,6 +71,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
grok: 'x.ai',
copilot: 'github.com',
opencode: 'opencode.ai',
omp: 'omp.sh',
gemini: 'gemini.google.com',
antigravity: 'antigravity.google',
goose: 'goose-docs.ai',
@@ -78,6 +83,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
autohand: 'autohand.ai',
cline: 'cline.bot',
codebuff: 'codebuff.com',
'command-code': 'commandcode.ai',
continue: 'continue.dev',
cursor: 'cursor.com',
droid: 'factory.ai',
@@ -96,6 +102,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record<TuiAgent, string> = {
copilot: 'copilot',
opencode: 'opencode',
pi: 'pi',
omp: 'omp',
gemini: 'gemini',
antigravity: 'agy',
aider: 'aider',
@@ -108,6 +115,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record<TuiAgent, string> = {
autohand: 'autohand',
cline: 'cline',
codebuff: 'codebuff',
'command-code': 'command-code',
continue: 'continue',
cursor: 'cursor-agent',
droid: 'droid',
+289 -47
View File
@@ -1,7 +1,8 @@
import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
@@ -20,6 +21,8 @@ export type TerminalKeyboardAvoidanceMetrics = {
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
@@ -28,6 +31,7 @@ export type TerminalSelectionEvents = {
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
}
export type TerminalWebViewHandle = {
@@ -48,18 +52,52 @@ export type TerminalWebViewHandle = {
type Props = {
style?: StyleProp<ViewStyle>
terminalTheme?: MobileTerminalTheme
onWebReady?: () => void
} & TerminalSelectionEvents
type TerminalMessage =
| { type: 'write'; id?: number; data: string }
| { type: 'init'; id?: number; cols: number; rows: number; initialData?: string }
| {
type: 'init'
id?: number
cols: number
rows: number
initialData?: string
terminalTheme?: MobileTerminalTheme
}
| { type: 'resize'; id?: number; cols: number; rows: number }
| { type: 'clear'; id?: number }
| { type: 'measure'; id?: number; containerHeight?: number }
| { type: 'reset-zoom'; id?: number }
| { type: 'cancel-select'; id?: number }
| { type: 'do-select-all'; id?: number }
| { type: 'set-theme'; id?: number; terminalTheme?: MobileTerminalTheme }
const DEFAULT_TERMINAL_THEME: MobileTerminalTheme['theme'] = {
background: colors.terminalBg,
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: colors.terminalBg,
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
}
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
// positioning designed for the desktop's terminal dimensions (~150+ cols).
@@ -95,6 +133,46 @@ const XTERM_HTML = `<!DOCTYPE html>
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; }
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.xterm .xterm-scrollable-element > .xterm-scrollbar,
.xterm .xterm-scrollbar {
display: none !important;
width: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
#scroll-indicator {
position: fixed;
top: 4px;
right: 3px;
bottom: 4px;
width: 3px;
pointer-events: none;
opacity: 0;
transition: opacity 120ms linear;
z-index: 7;
}
#scroll-indicator.visible { opacity: 0.72; }
#scroll-thumb {
position: absolute;
top: 0;
right: 0;
width: 3px;
min-height: 24px;
border-radius: 999px;
background: ${colors.textSecondary};
will-change: transform, height;
}
/* Why: selection overlay sits in unscaled viewport coords, above the
transformed surface, so handle hit areas and Copy menu positions
don't depend on getTotalScale() for their on-screen size. */
@@ -171,6 +249,7 @@ const XTERM_HTML = `<!DOCTYPE html>
<div id="terminal-container">
<div id="terminal-surface"></div>
</div>
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
<div id="selection-overlay">
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
@@ -187,6 +266,9 @@ const XTERM_HTML = `<!DOCTYPE html>
var C1_CSI = String.fromCharCode(155);
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
var writeQueue = [];
var writesDraining = false;
var afterDrainCallbacks = [];
@@ -195,8 +277,13 @@ const XTERM_HTML = `<!DOCTYPE html>
var userScale = 1;
var panX = 0;
var panY = 0;
var smoothScrollOffsetY = 0;
var pendingNormalScrollDeltaY = 0;
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalTheme = defaultTheme;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
@@ -253,9 +340,57 @@ const XTERM_HTML = `<!DOCTYPE html>
function updateTransform() {
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
updateScrollIndicator(false);
if (selMode === 'select') repositionOverlay();
}
function updateScrollIndicator(reveal) {
if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return;
var buffer = term.buffer.active;
var maxViewportY = buffer.baseY || 0;
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible');
return;
}
var trackHeight = Math.max(0, window.innerHeight - 8);
var totalRows = maxViewportY + (term.rows || 0);
if (trackHeight <= 0 || totalRows <= 0) return;
var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows);
var maxTop = Math.max(0, trackHeight - thumbHeight);
var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0;
scrollThumb.style.height = thumbHeight + 'px';
scrollThumb.style.transform = 'translateY(' + top + 'px)';
if (!reveal) return;
scrollIndicator.classList.add('visible');
if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer);
scrollIndicatorHideTimer = setTimeout(function() {
scrollIndicator.classList.remove('visible');
scrollIndicatorHideTimer = null;
}, 550);
}
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
: null;
if (!source) return defaultTheme;
var next = {};
var keys = Object.keys(defaultTheme);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (typeof source[key] === 'string') next[key] = source[key];
}
return Object.assign({}, defaultTheme, next);
}
function applyTerminalTheme(input) {
terminalTheme = normalizeTerminalTheme(input);
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
if (term) term.options.theme = terminalTheme;
}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
@@ -376,6 +511,7 @@ const XTERM_HTML = `<!DOCTYPE html>
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
@@ -494,7 +630,7 @@ const XTERM_HTML = `<!DOCTYPE html>
pumpWrites(terminalGeneration);
}
function init(cols, rows, initialData) {
function init(cols, rows, initialData, nextTheme) {
terminalGeneration++;
var gen = terminalGeneration;
ready = false;
@@ -503,6 +639,7 @@ const XTERM_HTML = `<!DOCTYPE html>
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
smoothScrollOffsetY = 0;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
@@ -535,32 +672,11 @@ const XTERM_HTML = `<!DOCTYPE html>
oldSurface.removeAttribute('id');
}
applyTerminalTheme(nextTheme);
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
theme: {
background: '${colors.terminalBg}',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '${colors.terminalBg}',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
},
theme: terminalTheme,
fontFamily: '"Menlo", "Consolas", "DejaVu Sans Mono", monospace',
fontSize: 13,
scrollback: 5000,
@@ -693,7 +809,7 @@ const XTERM_HTML = `<!DOCTYPE html>
if (handledMessageIds.length > 256) handledMessageIds.shift();
}
if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData);
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme);
} else if (msg.type === 'resize') {
resize(msg.cols, msg.rows);
} else if (msg.type === 'write') {
@@ -718,6 +834,8 @@ const XTERM_HTML = `<!DOCTYPE html>
measureFitDimensions(msg.containerHeight);
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg');
} else if (msg.type === 'set-theme') {
applyTerminalTheme(msg.terminalTheme);
} else if (msg.type === 'cancel-select') {
if (selMode === 'select') cancelSelect();
} else if (msg.type === 'do-select-all') {
@@ -849,6 +967,7 @@ const XTERM_HTML = `<!DOCTYPE html>
function attachTermObservers() {
if (!term) return;
try { term.onLineFeed(logFeedAndEvict); } catch (e) {}
try { term.onScroll(function() { updateScrollIndicator(false); }); } catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try {
@@ -975,6 +1094,10 @@ const XTERM_HTML = `<!DOCTYPE html>
return mode !== 'none' && mode !== 'x10';
}
function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
@@ -1024,6 +1147,83 @@ const XTERM_HTML = `<!DOCTYPE html>
term.scrollLines(lines);
}
function clampNormalScrollLines(lines) {
if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0;
var buffer = term.buffer.active;
if (lines > 0) {
return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY));
}
return Math.max(lines, -buffer.viewportY);
}
function canScrollNormalBufferDelta(deltaY) {
if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false;
var buffer = term.buffer.active;
if (deltaY > 0) return buffer.viewportY < buffer.baseY;
return buffer.viewportY > 0;
}
function applyNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
var effectiveCellH = getCellHeight() * getTotalScale();
if (effectiveCellH <= 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
if (lines !== 0) {
var applied = clampNormalScrollLines(lines);
if (applied !== 0) {
term.scrollLines(applied);
// Why: xterm's renderer is row-based. Buffer touch pixels and only
// commit whole rows so TUI canvas layers do not shimmer between
// fractional transforms and xterm repaints.
smoothScrollOffsetY += applied * effectiveCellH;
}
if (applied !== lines) smoothScrollOffsetY = 0;
}
var limit = effectiveCellH - 1;
if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit;
if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit;
updateScrollIndicator(true);
return true;
}
function enqueueNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true;
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
normalScrollFrameId = requestAnimationFrame(function() {
normalScrollFrameId = null;
var delta = pendingNormalScrollDeltaY;
pendingNormalScrollDeltaY = 0;
if (!applyNormalBufferScrollDelta(delta)) {
resetSmoothScrollOffset();
}
});
return true;
}
function resetSmoothScrollOffset() {
pendingNormalScrollDeltaY = 0;
if (normalScrollFrameId !== null) {
cancelAnimationFrame(normalScrollFrameId);
normalScrollFrameId = null;
}
if (smoothScrollOffsetY === 0) return;
smoothScrollOffsetY = 0;
updateScrollIndicator(false);
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
@@ -1339,6 +1539,9 @@ const XTERM_HTML = `<!DOCTYPE html>
return;
}
if (dispatch.mode === 'surface') {
if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') {
notify({ type: 'terminal-tap' });
}
clearLongPress();
if (e.touches.length === 0) {
dispatch.mode = 'idle';
@@ -1392,6 +1595,15 @@ const XTERM_HTML = `<!DOCTYPE html>
pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0
};
function updateTouchVelocity(deltaY, dt) {
if (dt <= 0) return;
var instantVelocity = deltaY / dt;
if (!isFinite(instantVelocity)) return;
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45;
}
function getDistance(a, b) {
var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
@@ -1413,6 +1625,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}
if (e.touches.length === 2) {
ts.isPinching = true;
smoothScrollOffsetY = 0;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
@@ -1458,20 +1671,30 @@ const XTERM_HTML = `<!DOCTYPE html>
var dt = now - ts.lastTime;
if (userScale > 1.05) {
smoothScrollOffsetY = 0;
panX += x - ts.lastX;
panY += y - ts.lastY;
clampPan();
updateTransform();
} else {
var deltaY = ts.lastY - y;
if (dt > 0) ts.velY = deltaY / dt;
ts.lastTime = now;
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
}
}
}
ts.lastX = x;
@@ -1501,17 +1724,26 @@ const XTERM_HTML = `<!DOCTYPE html>
if (e.touches.length === 0 && userScale <= 1.05) {
var vel = ts.velY;
var FRICTION = 0.95;
var MIN_VEL = 0.02;
var FRICTION = 0.972;
var MIN_VEL = 0.012;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += vel * 16;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
var delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
@@ -1561,6 +1793,7 @@ const XTERM_HTML = `<!DOCTYPE html>
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
style,
terminalTheme,
onWebReady,
onSelectionMode,
onSelectionCopy,
@@ -1568,7 +1801,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput
onTerminalInput,
onTerminalTap
},
ref
) {
@@ -1576,6 +1810,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const isWebReadyRef = useRef(false)
const pendingMessagesRef = useRef<TerminalMessage[]>([])
const messageIdRef = useRef(0)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
>(null)
@@ -1670,6 +1905,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
} else if (msg.type === 'terminal-input') {
const bytes = typeof msg.bytes === 'string' ? msg.bytes : ''
if (bytes.length > 0) onTerminalInput?.(bytes)
} else if (msg.type === 'terminal-tap') {
onTerminalTap?.()
} else if (msg.type === 'keyboard-avoidance-metrics') {
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
const rows = typeof msg.rows === 'number' ? msg.rows : 0
@@ -1702,7 +1939,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput
onTerminalInput,
onTerminalTap
]
)
@@ -1710,6 +1948,10 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
isWebReadyRef.current = false
}, [])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
useImperativeHandle(
ref,
() => ({
@@ -1733,7 +1975,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
readyPromiseRef.current = new Promise<void>((resolve) => {
readyResolveRef.current = resolve
})
postMessage({ type: 'init', cols, rows, initialData })
postMessage({ type: 'init', cols, rows, initialData, terminalTheme })
},
resize(cols: number, rows: number) {
postMessage({ type: 'resize', cols, rows })
@@ -1778,7 +2020,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
await Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, 3000))])
}
}),
[postMessage, sendToWebView]
[postMessage, sendToWebView, terminalTheme]
)
return (
@@ -1788,7 +2030,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
style={[styles.webview, style]}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={true}
scrollEnabled={false}
scalesPageToFit={false}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIVE_INPUT_MAX_BYTES,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit
} from './terminal-live-input'
describe('terminal live input', () => {
it('maps phone keyboard special keys to PTY bytes', () => {
expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f')
expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull()
expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull()
})
it('enforces the paste-sized byte budget', () => {
expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true)
expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true)
expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES + 1))).toBe(
false
)
expect(
isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1))
).toBe(false)
})
})
@@ -0,0 +1,19 @@
const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024
const encoder = new TextEncoder()
export function getTerminalLiveSpecialKeyBytes(key: string): string | null {
if (key === 'Backspace') {
return '\x7f'
}
return null
}
export function isTerminalLiveInputWithinByteLimit(
text: string,
maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES
): boolean {
return encoder.encode(text).byteLength <= maxBytes
}
export { TERMINAL_LIVE_INPUT_MAX_BYTES }
@@ -0,0 +1,110 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('TerminalWebView scroll routing', () => {
it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => {
expect(source).toContain(
'return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();'
)
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
)
expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))')
)
expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);')
})
it('does not rubber-band normal scroll at scrollback edges', () => {
expect(source).toContain('function canScrollNormalBufferDelta(deltaY)')
const smoothScrollBlock = sliceBetween(
'function applyNormalBufferScrollDelta(deltaY)',
'function enqueueNormalBufferScrollDelta(deltaY)'
)
expect(smoothScrollBlock).toContain('if (!canScrollNormalBufferDelta(deltaY))')
expect(smoothScrollBlock).toContain('resetSmoothScrollOffset();')
expect(smoothScrollBlock).toContain('return false;')
expect(smoothScrollBlock).toContain('return true;')
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
)
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
expect(touchMoveBlock).toContain('ts.velY = 0;')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
})
it('coalesces normal touch scroll row commits onto animation frames', () => {
const enqueueBlock = sliceBetween(
'function enqueueNormalBufferScrollDelta(deltaY)',
'function resetSmoothScrollOffset()'
)
expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;')
expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()')
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
'function resetSmoothScrollOffset()',
'function cellToViewportPx'
)
expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);')
})
it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => {
expect(source).toContain('<div id="scroll-indicator"><div id="scroll-thumb"></div></div>')
expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar')
expect(source).toContain('.xterm .xterm-scrollable-element > .xterm-scrollbar')
expect(source).toContain('overflow-y: hidden !important;')
expect(source).toContain('display: none !important;')
expect(source).toContain('function updateScrollIndicator(reveal)')
expect(source).toContain('buffer.viewportY / maxViewportY')
expect(source).not.toContain('fractionalRows')
expect(source).toContain('scrollThumb.style.transform =')
expect(source).toContain('updateScrollIndicator(true);')
})
it('does not apply fractional smooth scroll transforms to terminal content', () => {
const updateTransformBlock = sliceBetween(
'function updateTransform()',
'function updateScrollIndicator(reveal)'
)
expect(updateTransformBlock).toContain(
"surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';"
)
expect(source).not.toContain("querySelector('.xterm-screen')")
expect(source).not.toContain('updateTerminalScreenTransform')
expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('")
expect(updateTransformBlock).not.toContain('smoothScrollOffsetY')
})
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('var FRICTION = 0.972;')
expect(source).toContain('var MIN_VEL = 0.012;')
})
})
+14
View File
@@ -6,6 +6,7 @@ export const colors = {
bgPanel: '#1a1a1a',
bgRaised: '#242424',
borderSubtle: '#2a2a2a',
editorSurface: '#1e1e1e',
textPrimary: '#e0e0e0',
textSecondary: '#888888',
@@ -16,6 +17,19 @@ export const colors = {
statusGreen: '#22c55e',
statusAmber: '#f59e0b',
statusRed: '#ef4444',
gitDecorationAdded: '#81b88b',
gitDecorationDeleted: '#c74e39',
diffAddedBg: 'rgba(129, 184, 139, 0.1)',
diffDeletedBg: 'rgba(199, 78, 57, 0.11)',
syntaxComment: '#6a9955',
syntaxKeyword: '#569cd6',
syntaxString: '#ce9178',
syntaxNumber: '#b5cea8',
syntaxType: '#4ec9b0',
syntaxFunction: '#dcdcaa',
syntaxVariable: '#9cdcfe',
syntaxMeta: '#c586c0',
// Terminal WebView background (Tokyonight) — separate from app chrome
terminalBg: '#1a1b26'
@@ -196,7 +196,7 @@ final class Provider {
var action = try runAction()
do {
return try renderActionResult(action: action, snapshot: observe(params: params))
} catch let error as ProviderError where (error.code == "window_not_found" || error.code == "window_stale") && (requestedWindowId(params) != nil || requestedWindowIndex(params) != nil) {
} catch let error as ProviderError where (error.code == "window_not_found" || error.code == "window_stale") && hasRequestedWindowSelector(params) {
var fallbackParams = params
fallbackParams.removeValue(forKey: "windowId")
fallbackParams.removeValue(forKey: "windowIndex")
@@ -209,6 +209,8 @@ final class Provider {
private func observe(params: [String: JSONValue]) throws -> Snapshot {
let query = try requiredString(params, "app")
let windowId = try requestedWindowId(params)
let windowIndex = try requestedWindowIndex(params)
let app = try resolveApp(query)
if params["restoreWindow"]?.bool == true {
recoverWindow(app)
@@ -216,8 +218,8 @@ final class Provider {
let snapshot = try buildSnapshot(
app: app,
includeScreenshot: params["noScreenshot"]?.bool != true,
windowId: requestedWindowId(params),
windowIndex: requestedWindowIndex(params),
windowId: windowId,
windowIndex: windowIndex,
restoreWindow: params["restoreWindow"]?.bool == true
)
let keys = [query, app.name, app.bundleId ?? ""].filter { !$0.isEmpty }.map { $0.lowercased() }
@@ -226,13 +228,13 @@ final class Provider {
if !isExplicitSnapshotNamespace(namespace) {
snapshots[key] = snapshot
snapshots[snapshotWindowKey(key, snapshot.windowId)] = snapshot
if let windowIndex = requestedWindowIndex(params) {
if let windowIndex {
snapshots[snapshotWindowIndexKey(key, windowIndex)] = snapshot
}
}
snapshots[namespacedSnapshotKey(namespace, key)] = snapshot
snapshots[namespacedSnapshotKey(namespace, snapshotWindowKey(key, snapshot.windowId))] = snapshot
if let windowIndex = requestedWindowIndex(params) {
if let windowIndex {
snapshots[namespacedSnapshotKey(namespace, snapshotWindowIndexKey(key, windowIndex))] = snapshot
}
}
@@ -240,7 +242,7 @@ final class Provider {
}
private func currentSnapshot(params: [String: JSONValue]) throws -> Snapshot {
let cached = cachedSnapshot(params: params)
let cached = try cachedSnapshot(params: params)
// Why: cached AX frames can be stale after a window move or resize, and
// stale geometry can turn an intended action into a misclick.
let snapshot = try observe(params: params.merging(["noScreenshot": .bool(true)]) { _, replacement in replacement })
@@ -256,15 +258,15 @@ final class Provider {
return snapshot
}
private func cachedSnapshot(params: [String: JSONValue]) -> Snapshot? {
private func cachedSnapshot(params: [String: JSONValue]) throws -> Snapshot? {
guard let query = params["app"]?.string, !query.isEmpty else { return nil }
let namespace = snapshotNamespace(params)
if let targetWindowId = requestedWindowId(params) {
if let targetWindowId = try requestedWindowId(params) {
let windowKey = snapshotWindowKey(query.lowercased(), targetWindowId)
return snapshots[namespacedSnapshotKey(namespace, windowKey)] ??
(isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey])
}
if let targetWindowIndex = requestedWindowIndex(params) {
if let targetWindowIndex = try requestedWindowIndex(params) {
let windowKey = snapshotWindowIndexKey(query.lowercased(), targetWindowIndex)
return snapshots[namespacedSnapshotKey(namespace, windowKey)] ??
(isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey])
@@ -275,16 +277,15 @@ final class Provider {
}
private func validateRequestedElements(cached: Snapshot?, current: Snapshot, params: [String: JSONValue]) throws {
let requestedKeys = ["elementIndex", "fromElementIndex", "toElementIndex"].filter {
params[$0]?.number != nil
let requestedIndexes = try ["elementIndex", "fromElementIndex", "toElementIndex"].compactMap { key -> Int? in
guard params[key]?.number != nil else { return nil }
return try optionalInteger(params, key)
}
guard !requestedKeys.isEmpty else { return }
guard !requestedIndexes.isEmpty else { return }
guard let cached else {
throw ProviderError.coded("element_not_found", "element indexes require a fresh get-app-state snapshot for this app/window")
}
for key in requestedKeys {
guard let value = params[key]?.number else { continue }
let index = Int(value)
for index in requestedIndexes {
guard let expected = cached.elements[index], let actual = current.elements[index] else {
throw ProviderError.coded("element_not_found", "element \(index) is stale; run get-app-state again and use a fresh element index")
}
@@ -577,9 +578,9 @@ final class Provider {
private func click(params: [String: JSONValue]) throws -> [String: Any] {
let snapshot = try currentSnapshot(params: params)
let button = params["mouseButton"]?.string ?? "left"
let count = Int(params["clickCount"]?.number ?? 1)
if let elementIndex = params["elementIndex"]?.number {
let record = try element(snapshot, Int(elementIndex))
let count = try optionalInteger(params, "clickCount") ?? 1
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
return actionMetadata(path: "accessibility", actionName: actionName)
}
@@ -618,7 +619,7 @@ final class Provider {
private func performSecondaryAction(params: [String: JSONValue]) throws -> [String: Any] {
let snapshot = try currentSnapshot(params: params)
let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex")))
let record = try element(snapshot, try requiredInteger(params, "elementIndex"))
let requested = try requiredString(params, "action")
let action = record.actions.first { SnapshotRenderHeuristics.prettyAction($0).caseInsensitiveCompare(requested) == .orderedSame || $0.caseInsensitiveCompare(requested) == .orderedSame }
guard let action else {
@@ -632,7 +633,7 @@ final class Provider {
private func setValue(params: [String: JSONValue]) throws -> [String: Any] {
let snapshot = try currentSnapshot(params: params)
let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex")))
let record = try element(snapshot, try requiredInteger(params, "elementIndex"))
guard isSettable(record.element, kAXValueAttribute as String) else {
throw ProviderError.coded("value_not_settable", "element \(record.index) is not settable")
}
@@ -691,11 +692,12 @@ final class Provider {
let snapshot = try currentSnapshot(params: params)
let direction = try requiredString(params, "direction")
let pages = params["pages"]?.number ?? 1
if let elementIndex = params["elementIndex"]?.number {
let record = try element(snapshot, Int(elementIndex))
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
let action = "AXScroll\(direction.capitalized)ByPage"
if pages.rounded() == pages, record.actions.contains(action) {
for _ in 0..<max(1, Int(pages)) {
if pages.rounded() == pages, let pageCount = boundedInteger(pages, as: Int.self),
record.actions.contains(action) {
for _ in 0..<max(1, pageCount) {
_ = performAction(record.element, action)
}
return actionMetadata(path: "accessibility", actionName: action)
@@ -715,9 +717,10 @@ final class Provider {
let snapshot = try currentSnapshot(params: params)
let start: CGPoint
let end: CGPoint
if let fromIndex = params["fromElementIndex"]?.number, let toIndex = params["toElementIndex"]?.number {
let from = try element(snapshot, Int(fromIndex))
let to = try element(snapshot, Int(toIndex))
if let fromIndex = try optionalInteger(params, "fromElementIndex"),
let toIndex = try optionalInteger(params, "toElementIndex") {
let from = try element(snapshot, fromIndex)
let to = try element(snapshot, toIndex)
guard let fromPoint = center(from.localFrame, in: snapshot.windowBounds),
let toPoint = center(to.localFrame, in: snapshot.windowBounds)
else {
@@ -780,6 +783,21 @@ private func requiredNumber(_ params: [String: JSONValue], _ key: String) throws
return value
}
private func requiredInteger(_ params: [String: JSONValue], _ key: String) throws -> Int {
guard let value = boundedInteger(try requiredNumber(params, key), as: Int.self) else {
throw ProviderError.coded("invalid_argument", "\(key) is out of range")
}
return value
}
private func optionalInteger(_ params: [String: JSONValue], _ key: String) throws -> Int? {
guard let raw = params[key]?.number else { return nil }
guard let value = boundedInteger(raw, as: Int.self) else {
throw ProviderError.coded("invalid_argument", "\(key) is out of range")
}
return value
}
private func parsePid(_ query: String) -> pid_t? {
guard query.hasPrefix("pid:") else { return nil }
guard let pid = Int32(query.dropFirst(4)), pid > 0 else { return nil }
@@ -949,9 +967,16 @@ private func openBundle(_ bundleId: String) {
process.waitUntilExit()
}
private func requestedWindowId(_ params: [String: JSONValue]) -> CGWindowID? {
guard let value = params["windowId"]?.number, value >= 0 else { return nil }
return CGWindowID(UInt32(value))
private func hasRequestedWindowSelector(_ params: [String: JSONValue]) -> Bool {
params["windowId"] != nil || params["windowIndex"] != nil
}
private func requestedWindowId(_ params: [String: JSONValue]) throws -> CGWindowID? {
guard let raw = params["windowId"] else { return nil }
guard let value = raw.number, value >= 0, let id = boundedInteger(value, as: UInt32.self) else {
throw ProviderError.coded("invalid_argument", "windowId is out of range")
}
return CGWindowID(id)
}
private func snapshotWindowKey(_ query: String, _ windowId: CGWindowID) -> String {
@@ -980,9 +1005,12 @@ private func isExplicitSnapshotNamespace(_ namespace: String) -> Bool {
namespace != "default"
}
private func requestedWindowIndex(_ params: [String: JSONValue]) -> Int? {
guard let value = params["windowIndex"]?.number, value >= 0 else { return nil }
return Int(value)
private func requestedWindowIndex(_ params: [String: JSONValue]) throws -> Int? {
guard let raw = params["windowIndex"] else { return nil }
guard let value = raw.number, value >= 0, let index = boundedInteger(value, as: Int.self) else {
throw ProviderError.coded("invalid_argument", "windowIndex is out of range")
}
return index
}
private func usableWindow(_ element: AXUIElement) -> Bool {
@@ -1788,7 +1816,9 @@ private enum Input {
}
static func scroll(pid: pid_t, at point: CGPoint, direction: String, pages: Double) throws {
let delta = Int32(max(1, (12 * pages).rounded()))
guard let delta = boundedInteger(max(1, (12 * pages).rounded()), as: Int32.self) else {
throw ProviderError.coded("invalid_argument", "pages is out of range")
}
let wheel1: Int32 = direction == "up" ? delta : direction == "down" ? -delta : 0
let wheel2: Int32 = direction == "left" ? delta : direction == "right" ? -delta : 0
guard let event = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: wheel1, wheel2: wheel2, wheel3: 0) else {
@@ -2056,6 +2086,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
windowController = PermissionWindowController(
initialPermission: initialPermission,
terminateWhenDragAssistantCloses: initialPermission != nil
)
if let initialPermission {
@@ -2067,6 +2098,10 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
}
}
func applicationDidBecomeActive(_ notification: Notification) {
windowController?.refreshPermissions()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
initialPermission == nil
}
@@ -2074,9 +2109,11 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
private final class PermissionWindowController: NSWindowController {
private var dragAssistant: PermissionDragAssistantController?
private var dragAssistantPermission: PermissionKind?
private let initialPermission: PermissionKind?
private let terminateWhenDragAssistantCloses: Bool
convenience init(terminateWhenDragAssistantCloses: Bool = false) {
convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 315),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
@@ -2089,13 +2126,20 @@ private final class PermissionWindowController: NSWindowController {
window.backgroundColor = PermissionPalette.background
window.center()
window.isReleasedWhenClosed = false
self.init(window: window, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses)
window.contentView = PermissionView(frame: window.contentView?.bounds ?? .zero) { [weak self] permission in
self?.showDragAssistant(for: permission)
}
self.init(window: window, initialPermission: initialPermission, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses)
window.contentView = PermissionView(
frame: window.contentView?.bounds ?? .zero,
showDragAssistant: { [weak self] permission in
self?.showDragAssistant(for: permission)
},
close: { [weak self] in
self?.closePermissionWindow()
}
)
}
init(window: NSWindow?, terminateWhenDragAssistantCloses: Bool) {
init(window: NSWindow?, initialPermission: PermissionKind?, terminateWhenDragAssistantCloses: Bool) {
self.initialPermission = initialPermission
self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses
super.init(window: window)
}
@@ -2106,9 +2150,13 @@ private final class PermissionWindowController: NSWindowController {
private func showDragAssistant(for permission: PermissionKind) {
dragAssistant?.close()
dragAssistantPermission = permission
dragAssistant = PermissionDragAssistantController(
permission: permission,
fallbackVisibleFrame: window?.screen?.visibleFrame,
onRefreshPermissions: { [weak self] in
self?.refreshPermissions()
},
onClose: { [weak self] in
if self?.terminateWhenDragAssistantCloses == true {
NSApp.terminate(nil)
@@ -2118,13 +2166,57 @@ private final class PermissionWindowController: NSWindowController {
dragAssistant?.showWhenReady()
}
private func closeDragAssistant() {
dragAssistant?.close()
dragAssistant = nil
dragAssistantPermission = nil
}
private func completeDragAssistant() {
guard let dragAssistant else {
dragAssistantPermission = nil
if terminateWhenDragAssistantCloses {
NSApp.terminate(nil)
}
return
}
self.dragAssistant = nil
dragAssistantPermission = nil
dragAssistant.complete()
}
private func closePermissionWindow() {
// Why: the floating assistant is a separate retained window controller and
// can keep the helper app alive after the main permission window closes.
closeDragAssistant()
window?.close()
}
func openPermission(_ permission: PermissionKind) {
permission.requestAndOpenSettings()
showDragAssistant(for: permission)
}
func refreshPermissions() {
if let initialPermission, initialPermission.isGranted {
// Why: targeted permission helpers should finish once the requested
// grant lands, even if other Computer Use permissions remain unset.
completeDragAssistant()
return
}
if dragAssistantPermission?.isGranted == true {
// Why: after one grant in full setup, the remaining missing permission
// needs fresh guidance instead of the old assistant's instructions.
closeDragAssistant()
}
if PermissionKind.allCases.allSatisfy(\.isGranted) {
closeDragAssistant()
}
(window?.contentView as? PermissionView)?.refreshPermissions()
}
}
private enum PermissionKind {
private enum PermissionKind: CaseIterable {
case accessibility
case screenshots
@@ -2148,6 +2240,42 @@ private enum PermissionKind {
}
}
var title: String {
switch self {
case .accessibility:
"Accessibility"
case .screenshots:
"Screenshots"
}
}
var detail: String {
switch self {
case .accessibility:
"Read and control app interfaces"
case .screenshots:
"Capture windows for visual state"
}
}
var icon: NSImage {
switch self {
case .accessibility:
NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility") ?? NSImage()
case .screenshots:
NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording") ?? NSImage()
}
}
var isGranted: Bool {
switch self {
case .accessibility:
accessibilityTrusted()
case .screenshots:
screenCaptureTrusted()
}
}
func requestAndOpenSettings() {
switch self {
case .accessibility:
@@ -2162,9 +2290,13 @@ private enum PermissionKind {
private final class PermissionView: NSView {
private let appURL = Bundle.main.bundleURL
private let showDragAssistant: (PermissionKind) -> Void
private let close: () -> Void
private var contentStack: NSStackView?
private var contentConstraints: [NSLayoutConstraint] = []
init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void) {
init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) {
self.showDragAssistant = showDragAssistant
self.close = close
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = PermissionPalette.background.cgColor
@@ -2176,6 +2308,10 @@ private final class PermissionView: NSView {
}
private func build() {
NSLayoutConstraint.deactivate(contentConstraints)
contentStack?.removeFromSuperview()
contentConstraints = []
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .leading
@@ -2183,6 +2319,7 @@ private final class PermissionView: NSView {
stack.distribution = .gravityAreas
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
contentStack = stack
let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path))
icon.imageScaling = .scaleProportionallyUpOrDown
@@ -2192,9 +2329,12 @@ private final class PermissionView: NSView {
icon.heightAnchor.constraint(equalToConstant: 58)
])
let title = label("Enable Orca Computer Use", size: 22, weight: .bold)
let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted }
let ready = missingPermissions.isEmpty
let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold)
let subtitle = label(
"Grant permissions so Orca can use apps when you ask.",
ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.",
size: 12,
weight: .regular
)
@@ -2211,41 +2351,32 @@ private final class PermissionView: NSView {
header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true
stack.addArrangedSubview(permissionRow(
icon: NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility"),
title: "Accessibility",
detail: "Read and control app interfaces",
buttonTitle: "Allow"
) {
PermissionKind.accessibility.requestAndOpenSettings()
self.showDragAssistant(.accessibility)
})
if ready {
stack.addArrangedSubview(doneButton())
} else {
for permission in missingPermissions {
stack.addArrangedSubview(permissionRow(permission: permission) { [weak self] in
permission.requestAndOpenSettings()
self?.showDragAssistant(permission)
})
}
}
stack.addArrangedSubview(permissionRow(
icon: NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording"),
title: "Screenshots",
detail: "Capture windows for visual state",
buttonTitle: "Allow"
) {
PermissionKind.screenshots.requestAndOpenSettings()
self.showDragAssistant(.screenshots)
})
NSLayoutConstraint.activate([
contentConstraints = [
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 22),
stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20)
])
]
NSLayoutConstraint.activate(contentConstraints)
}
private func permissionRow(
icon: NSImage?,
title: String,
detail: String,
buttonTitle: String,
action: @escaping () -> Void
) -> NSView {
func refreshPermissions() {
// Why: TCC grants can change in System Settings while this window stays open.
build()
}
private func permissionRow(permission: PermissionKind, action: @escaping () -> Void) -> NSView {
let row = NSView()
row.wantsLayer = true
row.layer?.cornerRadius = 14
@@ -2254,13 +2385,13 @@ private final class PermissionView: NSView {
row.layer?.backgroundColor = PermissionPalette.card.cgColor
row.translatesAutoresizingMaskIntoConstraints = false
let iconView = NSImageView(image: icon ?? NSImage())
let iconView = NSImageView(image: permission.icon)
iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 30, weight: .regular)
iconView.contentTintColor = .controlAccentColor
iconView.translatesAutoresizingMaskIntoConstraints = false
let titleLabel = label(title, size: 13, weight: .bold)
let detailLabel = label(detail, size: 11, weight: .regular)
let titleLabel = label(permission.title, size: 13, weight: .bold)
let detailLabel = label(permission.detail, size: 11, weight: .regular)
detailLabel.textColor = PermissionPalette.secondaryText
let textStack = NSStackView(views: [titleLabel, detailLabel])
textStack.orientation = .vertical
@@ -2268,7 +2399,7 @@ private final class PermissionView: NSView {
textStack.spacing = 4
textStack.translatesAutoresizingMaskIntoConstraints = false
let button = NSButton(title: buttonTitle, target: nil, action: nil)
let button = NSButton(title: "Allow", target: nil, action: nil)
button.bezelStyle = .rounded
button.controlSize = .regular
button.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
@@ -2278,8 +2409,8 @@ private final class PermissionView: NSView {
.foregroundColor: NSColor.white,
.font: NSFont.systemFont(ofSize: 13, weight: .semibold)
]
button.attributedTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes)
button.attributedTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes)
let target = ButtonTarget(action)
button.target = target
button.action = #selector(ButtonTarget.run)
@@ -2306,6 +2437,29 @@ private final class PermissionView: NSView {
return row
}
private func doneButton() -> NSView {
let button = NSButton(title: "Done", target: nil, action: nil)
button.bezelStyle = .rounded
button.controlSize = .regular
button.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
button.contentTintColor = .white
button.bezelColor = .controlAccentColor
let buttonTitleAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.white,
.font: NSFont.systemFont(ofSize: 13, weight: .semibold)
]
button.attributedTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes)
let target = ButtonTarget(close)
button.target = target
button.action = #selector(ButtonTarget.run)
objc_setAssociatedObject(button, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 82).isActive = true
button.heightAnchor.constraint(equalToConstant: 32).isActive = true
return button
}
private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.font = NSFont.systemFont(ofSize: size, weight: weight)
@@ -2323,11 +2477,19 @@ private final class PermissionDragAssistantController: NSWindowController {
}
private let fallbackVisibleFrame: NSRect?
private let onRefreshPermissions: () -> Void
private let onClose: () -> Void
private var hasSeenSettingsWindow = false
private var followTimer: Timer?
private var isDismissed = false
private var scheduledShowWorkItems: [DispatchWorkItem] = []
convenience init(permission: PermissionKind, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) {
convenience init(
permission: PermissionKind,
fallbackVisibleFrame: NSRect?,
onRefreshPermissions: @escaping () -> Void,
onClose: @escaping () -> Void
) {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 390, height: 92),
styleMask: [.borderless, .nonactivatingPanel],
@@ -2347,15 +2509,25 @@ private final class PermissionDragAssistantController: NSWindowController {
window.isMovable = false
window.isMovableByWindowBackground = false
window.hasShadow = true
self.init(window: window, fallbackVisibleFrame: fallbackVisibleFrame, onClose: onClose)
window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self, weak window] in
window?.close()
self?.onClose()
self.init(
window: window,
fallbackVisibleFrame: fallbackVisibleFrame,
onRefreshPermissions: onRefreshPermissions,
onClose: onClose
)
window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self] in
self?.dismissFromCloseButton()
}
}
init(window: NSWindow?, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) {
init(
window: NSWindow?,
fallbackVisibleFrame: NSRect?,
onRefreshPermissions: @escaping () -> Void,
onClose: @escaping () -> Void
) {
self.fallbackVisibleFrame = fallbackVisibleFrame
self.onRefreshPermissions = onRefreshPermissions
self.onClose = onClose
super.init(window: window)
}
@@ -2365,31 +2537,52 @@ private final class PermissionDragAssistantController: NSWindowController {
}
func showWhenReady() {
guard !isDismissed else { return }
startFollowingSettingsWindow()
schedulePositionAndShow()
}
override func close() {
isDismissed = true
scheduledShowWorkItems.forEach { $0.cancel() }
scheduledShowWorkItems.removeAll()
followTimer?.invalidate()
followTimer = nil
super.close()
}
private func dismissFromCloseButton() {
// Why: closing the NSWindow directly skips this controller's timer cleanup,
// letting the assistant reappear while System Settings remains visible.
complete()
}
func complete() {
close()
onClose()
}
private func schedulePositionAndShow() {
scheduledShowWorkItems.forEach { $0.cancel() }
scheduledShowWorkItems.removeAll()
let delays = [0.12, 0.25, 0.4, 0.65, 0.95, 1.35, 1.8, 2.5]
for (index, delay) in delays.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, self.window?.isVisible != true else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self, !self.isDismissed, self.window?.isVisible != true else { return }
if let settingsWindow = self.systemSettingsWindowState(), settingsWindow.isVisible {
self.positionNearSettingsWindow(settingsWindow.frame)
guard !self.isDismissed else { return }
self.showWindow(nil)
self.window?.orderFrontRegardless()
} else if index == delays.count - 1 && self.systemSettingsIsFrontmost() {
self.positionFallback()
guard !self.isDismissed else { return }
self.showWindow(nil)
self.window?.orderFrontRegardless()
}
}
scheduledShowWorkItems.append(workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
@@ -2397,12 +2590,18 @@ private final class PermissionDragAssistantController: NSWindowController {
followTimer?.invalidate()
followTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.syncVisibilityWithSettingsWindow()
guard let self, !self.isDismissed else { return }
self.syncVisibilityWithSettingsWindow()
}
}
}
private func syncVisibilityWithSettingsWindow() {
guard !isDismissed else {
followTimer?.invalidate()
followTimer = nil
return
}
guard let window else {
followTimer?.invalidate()
followTimer = nil
@@ -2418,10 +2617,14 @@ private final class PermissionDragAssistantController: NSWindowController {
// Why: System Settings can stay visible on one display while the user
// works on another; follow actual occlusion instead of app focus.
if let settingsWindow, settingsWindow.isVisible {
onRefreshPermissions()
guard !isDismissed else { return }
positionNearSettingsWindow(settingsWindow.frame)
guard !isDismissed else { return }
if !window.isVisible {
showWindow(nil)
}
guard !isDismissed else { return }
window.orderFrontRegardless()
} else if window.isVisible {
window.orderOut(nil)
@@ -0,0 +1,10 @@
import Foundation
/// Converts a JSON-derived number to a fixed-width integer, truncating toward
/// zero like `Int(Double)` does, but returning nil instead of trapping when the
/// value is non-finite or outside the destination type's range. A malformed
/// request (e.g. `elementIndex: 1e300`) would otherwise crash the whole agent
/// process on the unchecked `Int(Double)` cast.
public func boundedInteger<T: BinaryInteger>(_ value: Double, as type: T.Type = T.self) -> T? {
T(exactly: value.rounded(.towardZero))
}
@@ -0,0 +1,41 @@
import XCTest
@testable import OrcaComputerUseMacOSCore
final class NumericArgumentParsingTests: XCTestCase {
func testConvertsIntegralValues() {
XCTAssertEqual(boundedInteger(5.0, as: Int.self), 5)
XCTAssertEqual(boundedInteger(0.0, as: Int.self), 0)
XCTAssertEqual(boundedInteger(-3.0, as: Int.self), -3)
}
func testTruncatesTowardZeroLikeIntInit() {
XCTAssertEqual(boundedInteger(5.9, as: Int.self), 5)
XCTAssertEqual(boundedInteger(-5.9, as: Int.self), -5)
}
// The crash this guards against: Int(1e300) traps because the value is
// finite but outside Int's range. A malformed request must not take the
// agent down.
func testReturnsNilForOutOfRangeValues() {
XCTAssertNil(boundedInteger(1e300, as: Int.self))
XCTAssertNil(boundedInteger(-1e300, as: Int.self))
}
func testReturnsNilForNonFiniteValues() {
XCTAssertNil(boundedInteger(.nan, as: Int.self))
XCTAssertNil(boundedInteger(.infinity, as: Int.self))
XCTAssertNil(boundedInteger(-.infinity, as: Int.self))
}
func testHonorsUnsignedDestinationBounds() {
XCTAssertEqual(boundedInteger(4.0, as: UInt32.self), 4)
XCTAssertNil(boundedInteger(-1.0, as: UInt32.self))
XCTAssertNil(boundedInteger(1e300, as: UInt32.self))
}
func testHonorsSignedFixedWidthDestinationBounds() {
XCTAssertEqual(boundedInteger(Double(Int32.max), as: Int32.self), Int32.max)
XCTAssertNil(boundedInteger(Double(Int32.max) + 1, as: Int32.self))
XCTAssertNil(boundedInteger(Double(Int32.min) - 1, as: Int32.self))
}
}
+26 -26
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.23-rc.3",
"version": "1.4.34",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -30,9 +30,10 @@
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
"typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false",
"start": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && electron-vite preview",
"dev": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "node config/scripts/ensure-native-runtime.mjs --runtime=electron && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"ensure:electron-runtime": "node config/scripts/ensure-native-runtime.mjs --runtime=electron",
"start": "pnpm run ensure:electron-runtime && electron-vite preview",
"dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
@@ -41,19 +42,19 @@
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build:web": "vite build --config vite.web.config.ts && node config/scripts/verify-web-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:web && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web",
"build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:node": "pnpm rebuild better-sqlite3 node-pty",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win",
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
"build:mac": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --mac",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --linux",
"test:e2e": "npx playwright test --config tests/playwright.config.ts --project electron-headless",
"test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful",
"build:mac": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts"
},
"dependencies": {
@@ -81,15 +82,15 @@
"@tiptap/pm": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@xterm/addon-fit": "0.12.0-beta.216",
"@xterm/addon-ligatures": "0.11.0-beta.216",
"@xterm/addon-search": "0.17.0-beta.216",
"@xterm/addon-serialize": "0.15.0-beta.216",
"@xterm/addon-unicode11": "0.10.0-beta.216",
"@xterm/addon-web-links": "0.13.0-beta.216",
"@xterm/addon-webgl": "0.20.0-beta.215",
"@xterm/headless": "6.1.0-beta.216",
"@xterm/xterm": "6.1.0-beta.216",
"@xterm/addon-fit": "0.12.0-beta.220",
"@xterm/addon-ligatures": "0.11.0-beta.220",
"@xterm/addon-search": "0.17.0-beta.220",
"@xterm/addon-serialize": "0.15.0-beta.220",
"@xterm/addon-unicode11": "0.10.0-beta.220",
"@xterm/addon-web-links": "0.13.0-beta.220",
"@xterm/addon-webgl": "0.20.0-beta.219",
"@xterm/headless": "6.1.0-beta.220",
"@xterm/xterm": "6.1.0-beta.220",
"agent-browser": "~0.24.1",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
@@ -202,15 +203,14 @@
"@parcel/watcher",
"better-sqlite3",
"cpu-features",
"electron",
"esbuild",
"node-pty",
"sherpa-onnx"
],
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.216": "config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch",
"@xterm/addon-webgl@0.20.0-beta.215": "config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch"
"@xterm/addon-ligatures@0.11.0-beta.220": "config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch",
"@xterm/addon-webgl@0.20.0-beta.219": "config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch"
}
}
}
+67 -67
View File
@@ -5,14 +5,14 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.216':
'@xterm/addon-ligatures@0.11.0-beta.220':
hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch
'@xterm/addon-webgl@0.20.0-beta.215':
hash: 4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d
path: config/patches/@xterm__addon-webgl@0.20.0-beta.215.patch
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch
'@xterm/addon-webgl@0.20.0-beta.219':
hash: b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea
path: config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch
node-pty@1.1.0:
hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -92,32 +92,32 @@ importers:
specifier: ^3.22.5
version: 3.22.5
'@xterm/addon-fit':
specifier: 0.12.0-beta.216
version: 0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)
specifier: 0.12.0-beta.220
version: 0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.216
version: 0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)
specifier: 0.11.0-beta.220
version: 0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-search':
specifier: 0.17.0-beta.216
version: 0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)
specifier: 0.17.0-beta.220
version: 0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-serialize':
specifier: 0.15.0-beta.216
version: 0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)
specifier: 0.15.0-beta.220
version: 0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.216
version: 0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)
specifier: 0.10.0-beta.220
version: 0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-web-links':
specifier: 0.13.0-beta.216
version: 0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)
specifier: 0.13.0-beta.220
version: 0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.215
version: 0.20.0-beta.215(patch_hash=4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d)(@xterm/xterm@6.1.0-beta.216)
specifier: 0.20.0-beta.219
version: 0.20.0-beta.219(patch_hash=b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea)(@xterm/xterm@6.1.0-beta.220)
'@xterm/headless':
specifier: 6.1.0-beta.216
version: 6.1.0-beta.216
specifier: 6.1.0-beta.220
version: 6.1.0-beta.220
'@xterm/xterm':
specifier: 6.1.0-beta.216
version: 6.1.0-beta.216
specifier: 6.1.0-beta.220
version: 6.1.0-beta.220
agent-browser:
specifier: ~0.24.1
version: 0.24.1
@@ -162,7 +162,7 @@ importers:
version: 0.55.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4)
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
@@ -3018,47 +3018,47 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-fit@0.12.0-beta.216':
resolution: {integrity: sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA==}
'@xterm/addon-fit@0.12.0-beta.220':
resolution: {integrity: sha512-lTiEjsdJBYTvIdA8USs90j+pu7/9oCQEh8JYBDJoRxzzAcvk4V1GlEA++Ztspbfk4nSh2HVxwJvdeTD6ffAQlg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-ligatures@0.11.0-beta.216':
resolution: {integrity: sha512-NJrK8MRi64Kb1Kxz5Jmrnd7H2gTlBqM3wHfObneD7T4xGFoPZkeX2/Kit4IcHt2Y5CTAQFAc92DilsncVm1HnQ==}
'@xterm/addon-ligatures@0.11.0-beta.220':
resolution: {integrity: sha512-fw/mSPqwgBNVX2ejW3rKhXablP3pelKJWYlt1QIr40+zZ//ERcHZWZNLB1I9fojiGW++tCnIHKh0GPpI2h5HgA==}
engines: {node: '>8.0.0'}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-search@0.17.0-beta.216':
resolution: {integrity: sha512-p//4yv5aB2eyQcCFmPQ/s7tTFSTwKbtxs89xP1gvMbT9QuZgjW9FNqaQKb1GLDWtMpSfZdy7RrUp4GiCg3PVJQ==}
'@xterm/addon-search@0.17.0-beta.220':
resolution: {integrity: sha512-2QwmGiZ8sn1ISYLuHIt0heUYN0wR6d+AWFZRGsekXbLCvRw/qjclCSKsDsIO1bTMBjey5K1MeZbse6kTKx+kDg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-serialize@0.15.0-beta.216':
resolution: {integrity: sha512-scj/83XfYHg5LiDscYDP9dnadUMZt8To1tbdncQP/dZq2pD4TsTMokW1pciVYYazLBxCeKZybbVLEuOo7IPCqQ==}
'@xterm/addon-serialize@0.15.0-beta.220':
resolution: {integrity: sha512-ssZ8rnSU0EOdDsFS5cI5LHKy/T0rQfZBE9iPph7rUxSAwZXbMGz0Vrsob79IKRvfE6oMZlFn+NE0Gg/mKq62Iw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-unicode11@0.10.0-beta.216':
resolution: {integrity: sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA==}
'@xterm/addon-unicode11@0.10.0-beta.220':
resolution: {integrity: sha512-83VMUPOp7VKWsVlw+a6TrZP38VAQM7xigu0JcSj2gVO1xsYRyme9wlgKDX+lmgWrfJPSjgB0vfXpcxI4dt/Wvw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-web-links@0.13.0-beta.216':
resolution: {integrity: sha512-ZP3BDy1na/37TZHO8FB+XHJFoO8muyPoOzkaL2X28n5A9ZFQPbf834EMRsvDczKnfQvtZcve+ZCmMJj1ZHGjow==}
'@xterm/addon-web-links@0.13.0-beta.220':
resolution: {integrity: sha512-XILFvFY0lOpTafIQxUOkS6x/MfZzarW3WbHZ40KEzAE8ImJqwOQiiqG17noLMUDD6rSOv7BRWNJACq4FflBNvg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/addon-webgl@0.20.0-beta.215':
resolution: {integrity: sha512-oCbH3YxiGOzRcKxwTfSRCA1TqpoT/AitO2X5MuqD14DVnb4Z3rTKQYfHBA7R6HA7U4K9OzmtIsi5+VyEIEaWsg==}
'@xterm/addon-webgl@0.20.0-beta.219':
resolution: {integrity: sha512-IaAcDDP0lhHIdYJuTFKZIMsRqlN8iR5U8lW9MiTK4mUAjettqZN2z/tkSsl4yV3GZ9/siOZofDl+GO1TK7y7ww==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.216
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/headless@6.1.0-beta.216':
resolution: {integrity: sha512-HCut3ABwBv5zVUAB+5KVTW4svr/fCeVMW9xtaJIhmXZsNHOrhVFOGX7E2CYzRa8xdX/aI2JdTIhgLl8xjcHEoA==}
'@xterm/headless@6.1.0-beta.220':
resolution: {integrity: sha512-oc+cI28eP/ktv91ddk1MxsEdfS41dnNvB3eMjNxfP7v+SaM7aXgXAL8Kj0/zzbpX/dG0nbA9wi3YvsybzCN3OQ==}
'@xterm/xterm@6.1.0-beta.216':
resolution: {integrity: sha512-87rfymzVje5eYUlGG94hz1WkOYvFRcFDGdiOAbg4d8xt8OGSGR2nMNU4I1n5MDE1RBPBqRd+WVJ5w7q3pwMoZA==}
'@xterm/xterm@6.1.0-beta.220':
resolution: {integrity: sha512-rwEKE75wfwfNWeGnjl0iQRA1W50HigKoGcGMeF3o5CqDSdI9IjmwzpV9xHXqLH6CYj6s9N1g6bbKFVDou9dJ/A==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
@@ -8922,39 +8922,39 @@ snapshots:
'@xmldom/xmldom@0.9.10':
optional: true
'@xterm/addon-fit@0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-fit@0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/addon-ligatures@0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-ligatures@0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
lru-cache: 6.0.0
opentype.js: 0.8.0
'@xterm/addon-search@0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-search@0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/addon-serialize@0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-serialize@0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/addon-unicode11@0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-unicode11@0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/addon-web-links@0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-web-links@0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/addon-webgl@0.20.0-beta.215(patch_hash=4923357da531172cda1fd570a33fd852349d9eb6317f4befa8b3af18c221039d)(@xterm/xterm@6.1.0-beta.216)':
'@xterm/addon-webgl@0.20.0-beta.219(patch_hash=b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea)(@xterm/xterm@6.1.0-beta.220)':
dependencies:
'@xterm/xterm': 6.1.0-beta.216
'@xterm/xterm': 6.1.0-beta.220
'@xterm/headless@6.1.0-beta.216': {}
'@xterm/headless@6.1.0-beta.220': {}
'@xterm/xterm@6.1.0-beta.216': {}
'@xterm/xterm@6.1.0-beta.220': {}
abbrev@4.0.0: {}
@@ -11367,7 +11367,7 @@ snapshots:
undici: 6.25.0
which: 6.0.1
node-pty@1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4):
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
dependencies:
node-addon-api: 7.1.1
+27
View File
@@ -10,4 +10,31 @@ describe('parseArgs', () => {
expect(parsed.flags.get('value')).toBe('')
expect(parsed.flags.get('json')).toBe(true)
})
it('accepts a flag value that starts with -- via the = form', () => {
const parsed = parseArgs(['terminal', 'send', '--text=--help'])
expect(parsed.commandPath).toEqual(['terminal', 'send'])
expect(parsed.flags.get('text')).toBe('--help')
})
it('splits --flag=value on the first = so values may contain =', () => {
const parsed = parseArgs(['set', 'cookie', '--value=a=b=c'])
expect(parsed.flags.get('value')).toBe('a=b=c')
})
it('treats --flag= as an empty string value', () => {
const parsed = parseArgs(['--value='])
expect(parsed.flags.get('value')).toBe('')
})
it('still parses boolean flags and space-separated values', () => {
const parsed = parseArgs(['tab', 'create', '--json', '--url', 'https://example.com'])
expect(parsed.commandPath).toEqual(['tab', 'create'])
expect(parsed.flags.get('json')).toBe(true)
expect(parsed.flags.get('url')).toBe('https://example.com')
})
})
+17 -2
View File
@@ -29,7 +29,17 @@ export function parseArgs(argv: string[]): ParsedArgs {
continue
}
const flag = token.slice(2)
const assignment = token.slice(2)
// Why: `--flag=value` is the only unambiguous way to pass a value that
// itself starts with `--` (e.g. `--text=--help`); the space-separated form
// treats a `--`-leading next token as a new flag, so it can't express one.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
continue
}
const flag = assignment
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
@@ -65,7 +75,9 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
return false
}
if (
['automations', 'repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])
['automations', 'repo', 'worktree', 'terminal', 'file', 'computer', 'note'].includes(
commandPath[0]
)
) {
return false
}
@@ -87,6 +99,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
'repo',
'worktree',
'terminal',
'file',
'tab',
'cookie',
'intercept',
@@ -98,8 +111,10 @@ export function isCommandGroup(commandPath: string[]): boolean {
'storage',
'orchestration',
'computer',
'agent',
'environment'
].includes(commandPath[0])) ||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&
['local', 'session'].includes(commandPath[1]))
+2 -3
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
@@ -90,7 +89,7 @@ describe('orca cli browser page targeting', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', {
page: 'page-1',
worktree: `path:${path.resolve('/tmp/repo/feature')}`
worktree: 'id:repo::/tmp/repo/feature'
})
})
@@ -124,7 +123,7 @@ describe('orca cli browser page targeting', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', {
index: undefined,
page: 'page-2',
worktree: `path:${path.resolve('/tmp/repo/feature')}`
worktree: 'id:repo::/tmp/repo/feature'
})
})
+4
View File
@@ -4,6 +4,7 @@ import { CORE_HANDLERS } from './handlers/core'
import { AUTOMATION_HANDLERS } from './handlers/automations'
import { REPO_HANDLERS } from './handlers/repo'
import { WORKTREE_HANDLERS } from './handlers/worktree'
import { FILE_HANDLERS } from './handlers/file'
import { TERMINAL_HANDLERS } from './handlers/terminal'
import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav'
import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact'
@@ -16,6 +17,7 @@ import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
export type HandlerContext = {
flags: Map<string, string | boolean>
@@ -33,6 +35,7 @@ function buildHandlers(): Map<string, CommandHandler> {
AUTOMATION_HANDLERS,
REPO_HANDLERS,
WORKTREE_HANDLERS,
FILE_HANDLERS,
TERMINAL_HANDLERS,
BROWSER_NAV_HANDLERS,
BROWSER_INTERACT_HANDLERS,
@@ -44,6 +47,7 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
AGENT_HOOK_HANDLERS,
ENVIRONMENT_HANDLERS
]
for (const group of groups) {
+163
View File
@@ -0,0 +1,163 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { randomUUID } from 'crypto'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
} from '../../main/agent-hooks/managed-agent-hook-controls'
import { getDefaultUserDataPath } from '../runtime-client'
type AgentHookCommandResult = {
enabled: boolean
settingsPath: string
appliedBy: 'runtime' | 'offline'
statuses: AgentHookInstallStatus[]
}
function getDataPath(): string {
return join(getDefaultUserDataPath(), 'orca-data.json')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readPersistedState(dataPath: string): PersistedState {
if (!existsSync(dataPath)) {
return getDefaultPersistedState(homedir())
}
try {
const parsed = JSON.parse(readFileSync(dataPath, 'utf-8'))
if (!isRecord(parsed)) {
throw new Error('file does not contain a JSON object')
}
return parsed as PersistedState
} catch (error) {
throw new RuntimeClientError(
'runtime_error',
`Could not read ${dataPath}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function writePersistedState(dataPath: string, state: PersistedState): void {
mkdirSync(dirname(dataPath), { recursive: true })
const tmpPath = join(dirname(dataPath), `.${Date.now()}-${randomUUID()}.tmp`)
let renamed = false
try {
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8')
renameSync(tmpPath, dataPath)
renamed = true
} finally {
if (!renamed && existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// best effort
}
}
}
}
function readEnabledFromDisk(): boolean {
const state = readPersistedState(getDataPath())
return state.settings?.agentStatusHooksEnabled !== false
}
function updateEnabledOnDisk(enabled: boolean): string {
const dataPath = getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
...getDefaultPersistedState(homedir()).settings,
...state.settings,
agentStatusHooksEnabled: enabled
}
writePersistedState(dataPath, state)
return dataPath
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
try {
const status = await client.getCliStatus()
if (!status.result.runtime.reachable) {
return false
}
await client.call(
'settings.update',
{ agentStatusHooksEnabled: enabled },
{ timeoutMs: 10_000 }
)
return true
} catch {
return false
}
}
function localSuccess<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
return {
id: 'local',
ok: true,
result,
_meta: {
runtimeId: 'local'
}
}
}
function formatAgentHookCommandResult(result: AgentHookCommandResult): string {
const statusSummary = result.statuses
.map((status) => `${status.agent}: ${status.state}`)
.join('\n')
return [
`agentStatusHooksEnabled: ${result.enabled}`,
`appliedBy: ${result.appliedBy}`,
`settingsPath: ${result.settingsPath}`,
statusSummary
]
.filter(Boolean)
.join('\n')
}
async function setAgentHooksEnabled(
client: RuntimeClient,
enabled: boolean
): Promise<AgentHookCommandResult> {
const updatedRuntime = await updateRunningRuntime(client, enabled)
const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled)
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: applyAgentStatusHooksEnabled(enabled)
return {
enabled,
settingsPath,
appliedBy: updatedRuntime ? 'runtime' : 'offline',
statuses
}
}
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks status': async ({ json }) => {
const result: AgentHookCommandResult = {
enabled: readEnabledFromDisk(),
settingsPath: getDataPath(),
appliedBy: 'offline',
statuses: getManagedAgentHookStatuses()
}
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks off': async ({ client, json }) => {
const result = await setAgentHooksEnabled(client, false)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
},
'agent hooks on': async ({ client, json }) => {
const result = await setAgentHooksEnabled(client, true)
printResult(localSuccess(result), json, formatAgentHookCommandResult)
}
}
+4 -5
View File
@@ -1,5 +1,4 @@
/* eslint-disable max-lines -- Why: computer CLI coverage shares one mocked runtime setup across command contracts. */
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
@@ -71,7 +70,7 @@ describe('orca computer CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listApps', {
worktree: `path:${path.resolve('/tmp/repo')}`
worktree: 'id:repo::/tmp/repo'
})
})
@@ -142,7 +141,7 @@ describe('orca computer CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.getAppState', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`,
worktree: 'id:repo::/tmp/repo',
noScreenshot: true,
restoreWindow: true
})
@@ -176,7 +175,7 @@ describe('orca computer CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listWindows', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`
worktree: 'id:repo::/tmp/repo'
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('[0] id:42 "Recents"')
@@ -241,7 +240,7 @@ describe('orca computer CLI handlers', () => {
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.drag', {
app: 'Finder',
worktree: `path:${path.resolve('/tmp/repo')}`,
worktree: 'id:repo::/tmp/repo',
fromElementIndex: undefined,
toElementIndex: undefined,
fromX: 1,
+329
View File
@@ -0,0 +1,329 @@
/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
this.isRemote = Boolean(remotePairingCode || environmentSelector)
}
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { main } from '../index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from '../test-fixtures'
describe('orca file CLI handlers', () => {
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('prints group help with file commands', async () => {
await main(['file', '--help'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('open')
expect(output).toContain('diff')
expect(output).toContain('open-changed')
})
it('opens a positional path in the inferred current worktree', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_open', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
})
)
await main(['file', 'open', 'src/App.tsx'], '/tmp/repo/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'src/App.tsx'
})
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.')
})
it('opens a staged diff for an explicit worktree without cwd inference', async () => {
queueFixtures(
callMock,
okFixture('req_diff', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
})
)
await main(
['file', 'diff', '--path', 'src/App.tsx', '--staged', '--worktree', 'id:wt-1'],
'/tmp/elsewhere'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
})
})
it('rejects --worktree without a value before cwd inference or RPC calls', async () => {
const priorExitCode = process.exitCode
await main(['file', 'open', 'src/App.tsx', '--worktree'], '/tmp/repo/src')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Missing value for --worktree.')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('opens git-changed files as diffs by default', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]),
okFixture('req_status', {
entries: [
{ path: 'src/App.tsx', status: 'modified', area: 'unstaged' },
{ path: 'package.json', status: 'modified', area: 'staged' },
{ path: 'docs/new.md', status: 'untracked', area: 'untracked' }
],
conflictOperation: 'unknown'
}),
okFixture('req_diff_1', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
}),
okFixture('req_diff_2', {
worktree: 'wt-1',
relativePath: 'package.json',
kind: 'text',
opened: true
}),
okFixture('req_diff_3', {
worktree: 'wt-1',
relativePath: 'docs/new.md',
kind: 'markdown',
opened: true
})
)
await main(['file', 'open-changed'], '/tmp/repo/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'git.status', {
worktree: 'id:repo::/tmp/repo'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'src/App.tsx',
staged: false
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'package.json',
staged: true
})
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
worktree: 'id:repo::/tmp/repo',
relativePath: 'docs/new.md',
staged: false
})
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.')
})
it('skips unresolved conflict entries in diff mode without opening a normal diff', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
entries: [
{
path: 'src/conflicted.ts',
status: 'modified',
area: 'unstaged',
conflictStatus: 'unresolved'
},
{ path: 'src/App.tsx', status: 'modified', area: 'staged' }
],
conflictOperation: 'merge'
}),
okFixture('req_diff', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
})
)
await main(['file', 'open-changed', '--worktree', 'id:wt-1'], '/tmp/elsewhere')
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' })
expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Opened 1 changed file targets.')
expect(output).toContain(
'src/conflicted.ts: unresolved conflict may not have a single diff target'
)
})
it('opens changed files in both edit and diff modes while skipping deleted edit targets', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
entries: [
{ path: 'src/App.tsx', status: 'modified', area: 'unstaged' },
{ path: 'src/App.tsx', status: 'modified', area: 'staged' },
{ path: 'docs/old.md', status: 'deleted', area: 'unstaged' }
],
conflictOperation: 'unknown'
}),
okFixture('req_open', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
}),
okFixture('req_diff_1', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
}),
okFixture('req_diff_2', {
worktree: 'wt-1',
relativePath: 'src/App.tsx',
kind: 'text',
opened: true
}),
okFixture('req_diff_3', {
worktree: 'wt-1',
relativePath: 'docs/old.md',
kind: 'markdown',
opened: true
})
)
await main(
['file', 'open-changed', '--mode', 'both', '--worktree', 'id:wt-1'],
'/tmp/elsewhere'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' })
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: false
})
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'src/App.tsx',
staged: true
})
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
worktree: 'id:wt-1',
relativePath: 'docs/old.md',
staged: false
})
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('Opened 4 changed file targets.')
expect(output).toContain('docs/old.md: deleted file has no edit target')
})
it('requires an explicit worktree for remote file commands', async () => {
const priorExitCode = process.exitCode
await main(['file', 'open-changed', '--pairing-code', 'remote-runtime'], '/tmp/repo/src')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Remote file commands require --worktree'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects --mode without a value before cwd inference or RPC calls', async () => {
const priorExitCode = process.exitCode
await main(['file', 'open-changed', '--mode'], '/tmp/repo/src')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Missing value for --mode. Use edit, diff, or both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('validates mode before resolving a worktree', async () => {
const priorExitCode = process.exitCode
await main(['file', 'open-changed', '--mode', 'invalid'], '/tmp/repo/src')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Invalid --mode. Use edit, diff, or both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+215
View File
@@ -0,0 +1,215 @@
import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types'
import type { RuntimeFileOpenResult } from '../../shared/runtime-types'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
type FileOpenMode = 'edit' | 'diff'
type OpenChangedMode = FileOpenMode | 'both'
type FileOpenRecord = {
path: string
mode: FileOpenMode
staged?: boolean
opened: boolean
kind?: RuntimeFileOpenResult['kind']
skipped?: boolean
reason?: string
}
type FileOpenChangedResult = {
worktree: string
mode: OpenChangedMode
opened: FileOpenRecord[]
skipped: FileOpenRecord[]
totalChanged: number
}
async function getFileWorktreeSelector({ flags, cwd, client }: HandlerContext): Promise<string> {
const worktree = flags.get('worktree')
if (flags.has('worktree') && (typeof worktree !== 'string' || worktree.length === 0)) {
throw new RuntimeClientError('invalid_argument', 'Missing value for --worktree.')
}
const explicit = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client)
if (explicit) {
return explicit
}
if (client.isRemote) {
throw new RuntimeClientError(
'invalid_argument',
'Remote file commands require --worktree because the client cwd cannot identify a server worktree.'
)
}
return await resolveCurrentWorktreeSelector(cwd, client)
}
function getOpenChangedMode(flags: Map<string, string | boolean>): OpenChangedMode {
const value = flags.get('mode')
if (flags.has('mode') && (typeof value !== 'string' || value.length === 0)) {
throw new RuntimeClientError(
'invalid_argument',
'Missing value for --mode. Use edit, diff, or both.'
)
}
const mode = getOptionalStringFlag(flags, 'mode') ?? 'diff'
if (mode === 'edit' || mode === 'diff' || mode === 'both') {
return mode
}
throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.')
}
function canOpenEntryForEdit(entry: GitStatusEntry): string | null {
if (entry.status === 'deleted') {
return 'deleted file has no edit target'
}
if (entry.conflictStatus === 'unresolved') {
return 'unresolved conflict may not have a single editable file'
}
return null
}
async function openFileEdit(
ctx: HandlerContext,
worktree: string,
path: string
): Promise<FileOpenRecord> {
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
worktree,
relativePath: path
})
return {
path,
mode: 'edit',
opened: result.result.opened,
kind: result.result.kind,
...(result.result.opened ? {} : { skipped: true, reason: `${result.result.kind} file` })
}
}
async function openFileDiff(
ctx: HandlerContext,
worktree: string,
path: string,
staged: boolean
): Promise<FileOpenRecord> {
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
worktree,
relativePath: path,
staged
})
return {
path,
mode: 'diff',
staged,
opened: result.result.opened,
kind: result.result.kind
}
}
function formatOpenChangedResult(result: FileOpenChangedResult): string {
if (result.totalChanged === 0) {
return 'No changed files.'
}
const lines = [`Opened ${result.opened.length} changed file targets.`]
if (result.skipped.length > 0) {
lines.push(`Skipped ${result.skipped.length} changed file targets:`)
for (const skipped of result.skipped) {
lines.push(`- ${skipped.path}: ${skipped.reason ?? 'not opened'}`)
}
}
return lines.join('\n')
}
function formatFileOpen(result: RuntimeFileOpenResult): string {
return result.opened
? `Opened ${result.relativePath}.`
: `Did not open ${result.relativePath}: ${result.kind} file.`
}
function formatFileDiff(result: RuntimeFileOpenResult): string {
return `Opened diff for ${result.relativePath}.`
}
export const FILE_HANDLERS: Record<string, CommandHandler> = {
'file open': async (ctx) => {
const relativePath = getRequiredStringFlag(ctx.flags, 'path')
const worktree = await getFileWorktreeSelector(ctx)
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
worktree,
relativePath
})
printResult(result, ctx.json, formatFileOpen)
},
'file diff': async (ctx) => {
const relativePath = getRequiredStringFlag(ctx.flags, 'path')
const staged = ctx.flags.get('staged') === true
const worktree = await getFileWorktreeSelector(ctx)
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
worktree,
relativePath,
staged
})
printResult(result, ctx.json, formatFileDiff)
},
'file open-changed': async (ctx) => {
const mode = getOpenChangedMode(ctx.flags)
const worktree = await getFileWorktreeSelector(ctx)
const status = await ctx.client.call<GitStatusResult>('git.status', { worktree })
const opened: FileOpenRecord[] = []
const skipped: FileOpenRecord[] = []
const openedEditPaths = new Set<string>()
for (const entry of status.result.entries) {
if (mode === 'edit' || mode === 'both') {
const editSkipReason = canOpenEntryForEdit(entry)
if (editSkipReason) {
skipped.push({
path: entry.path,
mode: 'edit',
opened: false,
skipped: true,
reason: editSkipReason
})
} else if (!openedEditPaths.has(entry.path)) {
openedEditPaths.add(entry.path)
const record = await openFileEdit(ctx, worktree, entry.path)
const records = record.opened ? opened : skipped
records.push(record)
}
}
if (mode === 'diff' || mode === 'both') {
const staged = entry.area === 'staged'
if (entry.conflictStatus === 'unresolved') {
skipped.push({
path: entry.path,
mode: 'diff',
staged,
opened: false,
skipped: true,
reason: 'unresolved conflict may not have a single diff target'
})
} else {
opened.push(await openFileDiff(ctx, worktree, entry.path, staged))
}
}
}
printResult(
{
...status,
result: {
worktree,
mode,
opened,
skipped,
totalChanged: status.result.entries.length
}
},
ctx.json,
formatOpenChangedResult
)
}
}
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
vi.mock('../format', () => ({ printResult: vi.fn() }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
describe('orchestration reset CLI handler', () => {
beforeEach(() => {
callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } })
})
const invoke = (flags: Map<string, string | boolean>) =>
ORCHESTRATION_HANDLERS['orchestration reset']({
flags,
client: { call: callMock },
json: true
} as never)
it('sends all: true for a bare `reset` (no scope flag)', async () => {
await invoke(new Map())
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
all: true,
tasks: undefined,
messages: undefined
})
})
it('sends only the tasks scope for --tasks', async () => {
await invoke(new Map([['tasks', true]]))
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
all: undefined,
tasks: true,
messages: undefined
})
})
it('sends only the all scope for --all (no implicit extra scopes)', async () => {
await invoke(new Map([['all', true]]))
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
all: true,
tasks: undefined,
messages: undefined
})
})
})
+2 -1
View File
@@ -438,8 +438,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
},
'orchestration reset': async ({ flags, client, json }) => {
const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages')
const result = await client.call<{ reset: string }>('orchestration.reset', {
all: flags.has('all') ? true : undefined,
all: flags.has('all') || !hasScopeFlag ? true : undefined,
tasks: flags.has('tasks') ? true : undefined,
messages: flags.has('messages') ? true : undefined
})
+13 -1
View File
@@ -42,6 +42,11 @@ Worktrees:
worktree rm Remove a worktree from Orca and git
worktree ps Show a compact orchestration summary across worktrees
Files:
file open Open a workspace file in the Orca editor
file diff Open a workspace file diff in the Orca editor
file open-changed Open all git-changed files for a workspace
Terminals:
terminal list List live Orca-managed terminals
terminal show Show terminal metadata and preview
@@ -167,6 +172,9 @@ Common Commands:
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca file open <path> [--worktree <selector>] [--json]
orca file diff <path> [--staged] [--worktree <selector>] [--json]
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
orca terminal show [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
@@ -252,6 +260,8 @@ Examples:
$ orca worktree current
$ orca worktree set --worktree active --comment "waiting on review"
$ orca worktree ps --limit 10
$ orca file open-changed --mode diff
$ orca file open src/App.tsx
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
@@ -353,6 +363,7 @@ export function formatFlagHelp(flag: string): string {
json: '--json Emit machine-readable JSON',
key: '--key <key> Key or combo to press, e.g. Escape or CmdOrCtrl+L',
limit: '--limit <n> Maximum number of rows to return',
mode: '--mode <mode> Mode such as edit, diff, or both',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage for unrelated work',
@@ -360,7 +371,7 @@ export function formatFlagHelp(flag: string): string {
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent selector; create infers the caller/current worktree by default',
path: '--path <path> Filesystem path to the repo',
path: '--path <path> Path argument for the command',
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
@@ -378,6 +389,7 @@ export function formatFlagHelp(flag: string): string {
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
prompt: '--prompt <text> Automation prompt to pass to the agent',
staged: '--staged Open staged source-control changes',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
+65 -14
View File
@@ -182,7 +182,10 @@ describe('orca cli worktree awareness', () => {
it('shows the enclosing worktree for `worktree current`', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
worktreeListFixture([
buildWorktree('/tmp/repo/feature', 'feature/foo'),
buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
@@ -197,7 +200,7 @@ describe('orca cli worktree awareness', () => {
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: `path:${path.resolve('/tmp/repo/feature')}`
worktree: 'id:repo::/tmp/repo/feature'
})
expect(logSpy).toHaveBeenCalledTimes(1)
})
@@ -245,7 +248,7 @@ describe('orca cli worktree awareness', () => {
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
worktree: 'id:repo::/tmp/repo/feature',
displayName: undefined,
linkedIssue: undefined,
comment: 'hello',
@@ -332,7 +335,7 @@ describe('orca cli worktree awareness', () => {
displayName: undefined,
linkedIssue: undefined,
comment: undefined,
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
parentWorktree: 'id:repo::/tmp/repo/parent',
noParent: false
})
})
@@ -437,7 +440,7 @@ describe('orca cli worktree awareness', () => {
runHooks: false,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined
})
@@ -541,7 +544,7 @@ describe('orca cli worktree awareness', () => {
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
parentWorktree: 'id:repo-1::/tmp/repo/parent',
noParent: false,
callerTerminalHandle: undefined
})
@@ -666,7 +669,7 @@ describe('orca cli worktree awareness', () => {
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: 'term_parent'
})
@@ -882,7 +885,7 @@ describe('orca cli worktree awareness', () => {
runHooks: true,
activate: true,
parentWorktree: undefined,
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined
})
@@ -1273,7 +1276,7 @@ describe('orca cli worktree awareness', () => {
await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: `path:${path.resolve('/tmp/repo/feature')}`
worktree: 'id:repo::/tmp/repo/feature'
})
})
@@ -1308,6 +1311,54 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
})
it('passes all reset scope explicitly for no-flag orchestration reset', async () => {
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'reset'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
all: true,
tasks: undefined,
messages: undefined
})
})
it.each([
{
args: ['orchestration', 'reset', '--all'],
params: { all: true, tasks: undefined, messages: undefined },
reset: 'all'
},
{
args: ['orchestration', 'reset', '--tasks'],
params: { all: undefined, tasks: true, messages: undefined },
reset: 'tasks'
},
{
args: ['orchestration', 'reset', '--messages'],
params: { all: undefined, tasks: undefined, messages: true },
reset: 'messages'
},
{
args: ['orchestration', 'reset', '--tasks', '--messages'],
params: { all: undefined, tasks: true, messages: true },
reset: 'tasks'
},
{
args: ['orchestration', 'reset', '--all', '--tasks'],
params: { all: true, tasks: true, messages: undefined },
reset: 'all'
}
])('passes explicit reset flags through for $args', async ({ args, params, reset }) => {
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(args, '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.reset', params)
})
it('rejects unknown task-update status with an enum-aware error', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -1397,7 +1448,7 @@ describe('orca cli worktree awareness', () => {
await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
worktree: 'id:repo::/tmp/repo/feature',
limit: undefined
})
})
@@ -1610,7 +1661,7 @@ describe('orca cli worktree awareness', () => {
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
reuseSession: undefined,
@@ -1823,7 +1874,7 @@ describe('orca cli worktree awareness', () => {
2,
'automation.create',
expect.objectContaining({
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
reuseSession: true
})
@@ -1950,7 +2001,7 @@ describe('orca cli worktree awareness', () => {
prompt: 'Review open changes',
agentId: 'codex',
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: 'existing',
baseBranch: undefined,
timezone: undefined,
@@ -1982,7 +2033,7 @@ describe('orca cli worktree awareness', () => {
prompt: undefined,
agentId: undefined,
repo: undefined,
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
workspace: 'id:repo-1::/tmp/repo/feature',
workspaceMode: undefined,
baseBranch: undefined,
reuseSession: undefined,
+3 -1
View File
@@ -17,7 +17,9 @@ export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return commandPath[0] === 'environment' || commandPath[0] === 'serve'
return (
commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent'
)
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
+4 -4
View File
@@ -69,10 +69,10 @@ export async function resolveCurrentWorktreeSelector(
}
// Why: users expect "active/current" to mean the enclosing managed worktree
// even from nested subdirectories. The CLI resolves that shell-local concept
// to the deepest matching worktree root, then hands the runtime a normal
// path selector so selector semantics stay centralized in one layer.
return buildCurrentWorktreeSelector(enclosingWorktree.path)
// even from nested subdirectories. Resolve to the concrete runtime id here:
// duplicate repo registrations can expose the same Git worktree path, and a
// path selector would throw selector_ambiguous after losing the repo id.
return `id:${enclosingWorktree.id}`
}
export async function getOptionalWorktreeSelector(
+26
View File
@@ -0,0 +1,26 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const AGENT_HOOK_COMMAND_SPECS: CommandSpec[] = [
{
path: ['agent', 'hooks', 'status'],
summary: 'Show whether Orca-managed agent status hooks are enabled',
usage: 'orca agent hooks status [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca agent hooks status', 'orca agent hooks status --json']
},
{
path: ['agent', 'hooks', 'off'],
summary: 'Disable Orca-managed agent status hooks and remove local hook entries',
usage: 'orca agent hooks off [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca agent hooks off']
},
{
path: ['agent', 'hooks', 'on'],
summary: 'Enable Orca-managed agent status hooks',
usage: 'orca agent hooks on [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca agent hooks on']
}
]
+48
View File
@@ -0,0 +1,48 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const FILE_COMMAND_SPECS: CommandSpec[] = [
{
path: ['file', 'open'],
summary: 'Open a workspace file in the Orca editor',
usage: 'orca file open <path> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree'],
positionalArgs: ['path'],
notes: [
'The path is relative to the selected worktree. When --worktree is omitted, local CLI calls infer the current Orca worktree from cwd.'
],
examples: [
'orca file open src/App.tsx',
'orca file open --path docs/readme.md --worktree active'
]
},
{
path: ['file', 'diff'],
summary: 'Open a workspace file diff in the Orca editor',
usage: 'orca file diff <path> [--staged] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree'],
positionalArgs: ['path'],
notes: [
'Diffs default to unstaged changes. Pass --staged to open the staged source-control diff.'
],
examples: [
'orca file diff src/App.tsx',
'orca file diff --path package.json --staged --worktree branch:feature'
]
},
{
path: ['file', 'open-changed'],
summary: 'Open all git-changed files for a workspace',
usage: 'orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree'],
notes: [
'For v1, changed files come from git status for the selected worktree.',
'The default mode is diff. Edit mode skips deleted files because there is no file to open.'
],
examples: [
'orca file open-changed',
'orca file open-changed --mode both',
'orca file open-changed --mode diff --worktree active'
]
}
]
+4
View File
@@ -3,16 +3,20 @@ import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced'
import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { AUTOMATION_COMMAND_SPECS } from './automations'
import { CORE_COMMAND_SPECS } from './core'
import { FILE_COMMAND_SPECS } from './file'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...FILE_COMMAND_SPECS,
...AUTOMATION_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS,
...AGENT_HOOK_COMMAND_SPECS,
...ENVIRONMENT_COMMAND_SPECS
]
+2 -2
View File
@@ -136,8 +136,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['orchestration', 'reset'],
summary: 'Reset orchestration state',
usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]',
summary: 'Reset orchestration state (one scope; bare command resets all)',
usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages']
}
]
@@ -0,0 +1,366 @@
/* eslint-disable max-lines -- Why: the orchestrator tests cover local, SSH,
retry, and post-generation race guards; splitting would duplicate mocks. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
const {
gitExecFileAsyncMock,
getGitUsernameMock,
getSshGitUsernameMock,
getSshGitProviderMock,
generateBranchNameMock,
resolveTextGenerationParamsMock,
prepareLocalEnvMock,
computeBranchNameMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getGitUsernameMock: vi.fn(() => 'you'),
getSshGitUsernameMock: vi.fn(async () => 'you'),
getSshGitProviderMock: vi.fn(() => undefined),
generateBranchNameMock: vi.fn(),
resolveTextGenerationParamsMock: vi.fn(),
prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })),
computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`)
}))
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock }))
vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock }))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock }))
vi.mock('../text-generation/commit-message-text-generation', () => ({
generateBranchNameFromContext: generateBranchNameMock,
resolveTextGenerationParams: resolveTextGenerationParamsMock
}))
vi.mock('../text-generation/commit-message-agent-environment', () => ({
prepareLocalCommitMessageAgentEnv: prepareLocalEnvMock
}))
vi.mock('../ipc/worktree-logic', () => ({ computeBranchName: computeBranchNameMock }))
import {
maybeAutoRenameBranchOnFirstWork,
resetFirstWorkBranchRenameState,
type FirstWorkBranchRenameDeps,
type FirstWorkBranchRenameEvent
} from './first-work-branch-rename'
const REPO_ID = 'repo1'
const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
const TAB_ID = 'tab-1'
const PANE_KEY = `${TAB_ID}:leaf-1`
const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'")
function gitResponder(opts: {
currentBranch: string
hasUpstream: boolean
existingRefs?: string[]
}) {
return async (args: string[]) => {
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
if (opts.hasUpstream) {
return { stdout: 'origin/x\n', stderr: '' }
}
throw noUpstreamError
}
if (args[0] === 'rev-parse') {
return { stdout: `${opts.currentBranch}\n`, stderr: '' }
}
if (args[0] === 'show-ref') {
const ref = args.at(-1) ?? ''
if ((opts.existingRefs ?? []).includes(ref)) {
return { stdout: '', stderr: '' }
}
throw new Error('not found')
}
if (args[0] === 'branch' && args[1] === '-m') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
}
}
function makeDeps(overrides: Partial<FirstWorkBranchRenameDeps> = {}): {
deps: FirstWorkBranchRenameDeps
onRenamed: ReturnType<typeof vi.fn>
setDisplayName: ReturnType<typeof vi.fn>
} {
const onRenamed = vi.fn()
const setDisplayName = vi.fn()
const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings
const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo
return {
onRenamed,
setDisplayName,
deps: {
getSettings: () => settings,
getRepo: () => repo,
getAgentEnvResolvers: () => undefined,
getCurrentDisplayName: () => 'Nautilus-8',
canRenameOrcaCreatedBranch: () => true,
setDisplayName,
resolveWorktreeIdForTab: () => WORKTREE_ID,
onRenamed,
...overrides
}
}
}
function workingEvent(
overrides: Partial<FirstWorkBranchRenameEvent> = {}
): FirstWorkBranchRenameEvent {
return {
paneKey: PANE_KEY,
tabId: TAB_ID,
worktreeId: undefined,
state: 'working',
prompt: 'Fix the auth bug',
assistantMessage: undefined,
isReplay: false,
...overrides
}
}
describe('maybeAutoRenameBranchOnFirstWork', () => {
beforeEach(() => {
resetFirstWorkBranchRenameState()
vi.clearAllMocks()
getGitUsernameMock.mockReturnValue('you')
getSshGitUsernameMock.mockResolvedValue('you')
getSshGitProviderMock.mockReturnValue(undefined)
computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`)
prepareLocalEnvMock.mockResolvedValue({ ok: true })
resolveTextGenerationParamsMock.mockReturnValue({
ok: true,
params: { agentId: 'claude', model: 'm' }
})
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'fix-auth' })
gitExecFileAsyncMock.mockImplementation(
gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })
)
})
it('renames a fresh creature branch and its display name from the generated slug', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith(
expect.anything(),
'local',
'branchName',
expect.objectContaining({ id: REPO_ID })
)
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth')
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('leaves a user-customized display name untouched while still renaming the branch', async () => {
const { deps, setDisplayName } = makeDeps({ getCurrentDisplayName: () => 'My cool feature' })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(setDisplayName).not.toHaveBeenCalled()
})
it('resolves the worktree from the tab when the hook payload omits worktreeId', async () => {
// workingEvent() carries worktreeId: undefined; resolveWorktreeIdForTab supplies it.
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('skips when no worktree can be resolved for the tab', async () => {
const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined })
await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does nothing when the setting is off', async () => {
const settings = { autoRenameBranchFromWork: false } as unknown as GlobalSettings
const { deps } = makeDeps({ getSettings: () => settings })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('ignores replayed events and non-working states', async () => {
const { deps } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps)
await maybeAutoRenameBranchOnFirstWork(workingEvent({ state: 'done' }), deps)
await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: ' ' }), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not re-attempt after a successful rename', async () => {
const { deps } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
generateBranchNameMock.mockClear()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(generateBranchNameMock).not.toHaveBeenCalled()
})
it('retries on a later event after a transient failure (does not poison the worktree)', async () => {
generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' })
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(onRenamed).not.toHaveBeenCalled()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
it('leaves a user-named branch untouched', async () => {
gitExecFileAsyncMock.mockImplementation(
gitResponder({ currentBranch: 'you/my-feature', hasUpstream: false })
)
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
})
it('refuses to rename a branch that already has an upstream', async () => {
gitExecFileAsyncMock.mockImplementation(
gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: true })
)
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
})
it('leaves ineligible branches untouched even when their leaf is a creature name', async () => {
const { deps, onRenamed } = makeDeps({ canRenameOrcaCreatedBranch: () => false })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
})
it('suffixes when the generated branch name already exists', async () => {
gitExecFileAsyncMock.mockImplementation(
gitResponder({
currentBranch: 'you/Nautilus',
hasUpstream: false,
existingRefs: ['refs/heads/you/fix-auth']
})
)
const { deps } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth-2'],
expect.objectContaining({ cwd: '/repo/wt' })
)
})
it('does not rename when the branch changes while generation is running', async () => {
let branchReadCount = 0
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
branchReadCount += 1
return { stdout: `${branchReadCount === 1 ? 'you/Nautilus' : 'you/manual'}\n`, stderr: '' }
}
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
throw noUpstreamError
}
if (args[0] === 'symbolic-ref') {
return { stdout: 'you/Nautilus\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/you/Nautilus')) {
throw new Error('not found')
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.anything()
)
expect(onRenamed).not.toHaveBeenCalled()
})
it('does not rename when the branch gains an upstream while generation is running', async () => {
let upstreamReadCount = 0
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return { stdout: 'you/Nautilus\n', stderr: '' }
}
if (args[0] === 'symbolic-ref') {
return { stdout: 'you/Nautilus\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
upstreamReadCount += 1
if (upstreamReadCount === 1) {
throw noUpstreamError
}
return { stdout: 'origin/you/Nautilus\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/you/Nautilus')) {
throw new Error('not found')
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const { deps, onRenamed } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.anything()
)
expect(onRenamed).not.toHaveBeenCalled()
})
it('uses the SSH git provider and remote generation target for remote worktrees', async () => {
getSshGitUsernameMock.mockResolvedValue('remote-user')
const provider = {
exec: vi.fn(gitResponder({ currentBranch: 'remote-user/Nautilus', hasUpstream: false })),
renameCurrentBranch: vi.fn(async () => undefined),
executeCommitMessagePlan: vi.fn()
}
getSshGitProviderMock.mockReturnValue(provider as never)
const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo
const { deps } = makeDeps({ getRepo: () => repo })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(prepareLocalEnvMock).not.toHaveBeenCalled()
expect(generateBranchNameMock).toHaveBeenCalledWith(
{ firstPrompt: 'Fix the auth bug', assistantMessage: undefined },
{ agentId: 'claude', model: 'm' },
expect.objectContaining({
kind: 'remote',
cwd: '/repo/wt',
missingBinaryLocation: 'remote PATH'
})
)
expect(computeBranchNameMock).toHaveBeenCalledWith('fix-auth', expect.anything(), 'remote-user')
expect(provider.exec).not.toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], '/repo/wt')
expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth')
})
it('retries when the SSH provider is unavailable on the first working event', async () => {
const provider = {
exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })),
renameCurrentBranch: vi.fn(async () => undefined),
executeCommitMessagePlan: vi.fn()
}
getSshGitProviderMock.mockReturnValueOnce(undefined).mockReturnValue(provider as never)
const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo
const { deps, onRenamed } = makeDeps({ getRepo: () => repo })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(onRenamed).not.toHaveBeenCalled()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth')
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
})
})
@@ -0,0 +1,262 @@
// Why: when "Auto-Rename Branch From Work" is on, the first time an agent
// starts working in a freshly-created workspace we replace the auto-generated
// creature branch (e.g. `you/Nautilus`) with a short, work-derived name. This
// module owns the orchestration: gate on the signal, enforce the safety
// guardrails, summarize the prompt via the configured agent, and rename.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
humanizeBranchSlug,
isAutoGeneratedCreatureBranchName
} from '../../shared/branch-name-from-work'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
import { getGitUsername } from '../git/repo'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import {
branchHasUpstream,
renameCurrentBranch,
resolveUniqueBranchName,
type GitExec
} from '../git/branch-rename'
import {
generateBranchNameFromContext,
resolveTextGenerationParams,
type CommitMessageGenerationTarget
} from '../text-generation/commit-message-text-generation'
import {
prepareLocalCommitMessageAgentEnv,
type CommitMessageAgentEnvironmentResolvers
} from '../text-generation/commit-message-agent-environment'
export type FirstWorkBranchRenameEvent = {
paneKey: string
/** Agent-echoed worktree id; unreliable, used only as a fallback. */
worktreeId: string | undefined
tabId: string | undefined
state: string
prompt: string | undefined
assistantMessage: string | undefined
isReplay: boolean | undefined
}
export type FirstWorkBranchRenameDeps = {
getSettings: () => GlobalSettings
getRepo: (repoId: string) => Repo | undefined
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
/** Current sidebar display name for the worktree, if one is stored. */
getCurrentDisplayName: (worktreeId: string) => string | undefined
/** True only for Orca-created worktrees whose branch Orca is allowed to rename. */
canRenameOrcaCreatedBranch: (worktreeId: string) => boolean
/** Persist a new sidebar display name for the worktree. */
setDisplayName: (worktreeId: string, displayName: string) => void
/** Authoritative tab→worktree resolution from the session's tab map. */
resolveWorktreeIdForTab: (tabId: string) => string | undefined
/** Invalidate caches + notify the renderer so the new branch name surfaces. */
onRenamed: (repoId: string) => void
}
// Why: `inFlight` guards against concurrent runs while generation (seconds) is
// pending; `settled` short-circuits worktrees we've reached a definitive verdict
// on (renamed, or permanently ineligible) so we never re-probe git on later
// `working` events — important because the hook fires every turn for every pane.
// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT
// settled, so the real first prompt can still succeed on a later event.
const inFlightWorktreeIds = new Set<string>()
const settledWorktreeIds = new Set<string>()
/** Test seam: clear the per-process dedupe sets. */
export function resetFirstWorkBranchRenameState(): void {
inFlightWorktreeIds.clear()
settledWorktreeIds.clear()
}
export async function maybeAutoRenameBranchOnFirstWork(
event: FirstWorkBranchRenameEvent,
deps: FirstWorkBranchRenameDeps
): Promise<void> {
// Cheap gates first — the signal must be a live "work has begun" event.
if (event.isReplay || event.state !== 'working') {
return
}
if (!deps.getSettings().autoRenameBranchFromWork) {
return
}
// Resolve the worktree from the tab (authoritative) rather than the
// agent-echoed worktreeId, which is not present on every hook event.
const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId
const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId
if (!worktreeId) {
return
}
// Short-circuit settled/in-flight worktrees before any logging or work.
if (settledWorktreeIds.has(worktreeId) || inFlightWorktreeIds.has(worktreeId)) {
return
}
const prompt = event.prompt?.trim()
if (!prompt) {
return
}
inFlightWorktreeIds.add(worktreeId)
try {
// `settled` true means a definitive verdict (renamed or permanently
// ineligible); false means a transient bail that should retry later.
const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps)
if (settled) {
settledWorktreeIds.add(worktreeId)
}
} catch (error) {
// Why: best-effort, opt-in convenience. A failure must never disrupt the
// agent the user actually launched, so swallow after logging.
console.warn('[auto-branch-rename] rename attempt failed:', error)
} finally {
inFlightWorktreeIds.delete(worktreeId)
}
}
/**
* Returns true when a definitive verdict was reached (renamed, or permanently
* ineligible — so stop re-probing), false for a transient bail worth retrying.
*/
async function runAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps
): Promise<boolean> {
// Why: when the feature appears not to fire, the question is always "which
// gate bailed?" — this names the gate so a single log line answers it.
// `stop` = permanent (don't retry); `retry` = transient (try again later).
const stop = (reason: string): true => {
console.info(`[auto-branch-rename] skip (${reason}) for ${worktreeId}`)
return true
}
const retry = (reason: string): false => {
console.info(`[auto-branch-rename] retry-later (${reason}) for ${worktreeId}`)
return false
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
if (!repo || !parsed) {
return stop('unresolved repo or worktree id')
}
const worktreePath = parsed.worktreePath
const provider = repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null
if (repo.connectionId && !provider) {
return retry('ssh provider unavailable')
}
const exec: GitExec = provider
? (args) => provider.exec(args, worktreePath)
: (args) => gitExecFileAsync(args, { cwd: worktreePath })
const currentBranch = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (!currentBranch || currentBranch === 'HEAD') {
return retry(`no checked-out branch (${currentBranch || 'empty'})`)
}
if (!deps.canRenameOrcaCreatedBranch(worktreeId)) {
return stop(`worktree is not eligible for auto-rename`)
}
// Only rename a branch Orca auto-named — never one the user chose.
const leaf = currentBranch.slice(currentBranch.lastIndexOf('/') + 1)
if (!isAutoGeneratedCreatureBranchName(leaf)) {
return stop(`branch "${currentBranch}" is not an auto-generated name`)
}
// Never rewrite a branch that's already been published.
if (await branchHasUpstream(exec)) {
return stop(`branch "${currentBranch}" already has an upstream`)
}
const settings = deps.getSettings()
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo)
if (!resolvedParams.ok) {
return stop(`no generation agent: ${resolvedParams.error}`)
}
const params = resolvedParams.params
const target = await resolveGenerationTarget(worktreePath, params.agentId, provider, deps)
if (!target) {
return retry('could not prepare generation environment')
}
const generated = await generateBranchNameFromContext(
{ firstPrompt: prompt, assistantMessage },
params,
target
)
if (!generated.success) {
// Transient: the agent may be momentarily busy/unreachable — let a later
// event retry rather than permanently leaving the creature name.
return retry(`generation failed: ${generated.error}`)
}
// Re-validate after generation (it can take seconds): the branch must be the
// same unpublished creature branch we started from, or we leave it alone.
const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branchNow !== currentBranch) {
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
}
if (await branchHasUpstream(exec)) {
return retry(`branch "${currentBranch}" was pushed during generation`)
}
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: getGitUsername(repo.path) || null
const newBranch = await resolveUniqueBranchName(
exec,
generated.slug,
(slugLeaf) => computeBranchName(slugLeaf, settings, username),
currentBranch
)
if (!newBranch || newBranch === currentBranch) {
return stop(`no distinct unique branch name for slug "${generated.slug}"`)
}
await (provider
? provider.renameCurrentBranch(worktreePath, newBranch)
: renameCurrentBranch(exec, newBranch))
// Keep the sidebar name in sync with the branch — but only when it is still
// the auto-generated creature name, so a name the user typed is left alone.
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
const newDisplayName = humanizeBranchSlug(generated.slug)
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
if (updateDisplay) {
deps.setDisplayName(worktreeId, newDisplayName)
}
deps.onRenamed(repo.id)
const displayLog = updateDisplay
? `display "${currentDisplayName ?? ''}" -> "${newDisplayName}"`
: `display kept ("${currentDisplayName}")`
console.info(`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}`)
return true
}
async function resolveGenerationTarget(
worktreePath: string,
agentId: string,
provider: SshGitProvider | null,
deps: FirstWorkBranchRenameDeps
): Promise<CommitMessageGenerationTarget | null> {
if (provider) {
return {
kind: 'remote',
cwd: worktreePath,
execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
missingBinaryLocation: 'remote PATH'
}
}
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers())
if (!localEnv.ok) {
return null
}
return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) }
}
+2
View File
@@ -18,6 +18,8 @@ export type HookCommandConfig = {
type: 'command'
command: string
timeout?: number
async?: boolean
statusMessage?: string
[key: string]: unknown
}
@@ -0,0 +1,110 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import type { GlobalSettings } from '../../shared/types'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void]
type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()]
]
const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
['claude', () => claudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()]
]
const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()]
]
export function isAgentStatusHooksEnabled(
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
): boolean {
return settings?.agentStatusHooksEnabled !== false
}
export function installManagedAgentHooks(): void {
for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
try {
install()
} catch (error) {
console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
}
}
}
function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus {
return {
agent,
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: error instanceof Error ? error.message : String(error)
}
}
export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => {
try {
return remove()
} catch (error) {
return errorStatus(agent, error)
}
})
}
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
try {
return getStatus()
} catch (error) {
return errorStatus(agent, error)
}
})
}
export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] {
if (enabled) {
installManagedAgentHooks()
return getManagedAgentHookStatuses()
}
return removeManagedAgentHooks()
}
@@ -10,6 +10,7 @@ vi.mock('electron', () => ({
import { CodexHookService } from '../codex/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { AntigravityHookService } from '../antigravity/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
@@ -24,9 +25,12 @@ type FakeFs = {
failRenameTo: Set<string>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
function createFakeSftp(initialFiles: Record<string, string> = {}): {
sftp: SFTPWrapper
fs: FakeFs
} {
const fs: FakeFs = {
files: new Map(),
files: new Map(Object.entries(initialFiles)),
dirs: new Set(['/']),
modes: new Map(),
failRenameTo: new Set()
@@ -135,6 +139,11 @@ describe('remote hook service installers', () => {
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/command-code-hook.sh',
install: (sftp: SFTPWrapper) =>
new CommandCodeHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/grok-hook.sh',
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev')
@@ -203,25 +212,28 @@ describe('remote hook service installers', () => {
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
})
it('installs remote Gemini, Antigravity, Cursor, and Grok configs using their CLI-specific schemas', async () => {
it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => {
const gemini = createFakeSftp()
const antigravity = createFakeSftp()
const cursor = createFakeSftp()
const commandCode = createFakeSftp()
const grok = createFakeSftp()
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev')
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
await new CommandCodeHookService().installRemote(commandCode.sftp, '/home/dev')
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) {
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(geminiConfig.hooks.PreToolUse).toBeUndefined()
const antigravityConfig = JSON.parse(
antigravity.fs.files.get('/home/dev/.gemini/config/hooks.json')!
@@ -265,6 +277,21 @@ describe('remote hook service installers', () => {
expect(definition?.hooks).toBeUndefined()
}
const commandCodeConfig = JSON.parse(
commandCode.fs.files.get('/home/dev/.commandcode/settings.json')!
) as {
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
for (const eventName of ['PreToolUse', 'PostToolUse', 'Stop']) {
const definition = commandCodeConfig.hooks[eventName]?.[0]
const command = definition?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/command-code-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(commandCodeConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*')
expect(commandCodeConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*')
expect(commandCodeConfig.hooks.Stop?.[0]?.matcher).toBeUndefined()
const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as {
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
@@ -335,6 +362,54 @@ describe('remote hook service installers', () => {
expect(postToolCommands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true)
})
it('removes stale remote Gemini PreToolUse hooks while preserving user-authored hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set(
'/home/dev/.gemini/settings.json',
`${JSON.stringify(
{
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command:
"if [ -x '/tmp/old/agent-hooks/gemini-hook.sh' ]; then /bin/sh '/tmp/old/agent-hooks/gemini-hook.sh'; fi"
}
]
},
{
hooks: [
{
type: 'command',
command: 'echo user-authored'
}
]
}
]
}
},
null,
2
)}\n`
)
await new GeminiHookService().installRemote(sftp, '/home/dev')
const config = JSON.parse(fs.files.get('/home/dev/.gemini/settings.json')!) as {
hooks: Record<string, { hooks?: { command: string }[] }[]>
}
const preToolCommands = config.hooks.PreToolUse.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
expect(preToolCommands).toEqual(['echo user-authored'])
const beforeToolCommands = config.hooks.BeforeTool.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
expect(beforeToolCommands.some((command) => command.includes('gemini-hook.sh'))).toBe(true)
})
it('installs remote Copilot hooks under the user-level hooks directory', async () => {
const { sftp, fs } = createFakeSftp()
fs.dirs.add('/home/dev/.copilot')
@@ -5,6 +5,7 @@ import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
@@ -19,6 +20,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)],
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)]
]
+392 -4
View File
@@ -973,6 +973,30 @@ describe('AgentHookServer listener replay', () => {
expect(rendererListener).not.toHaveBeenCalled()
})
it('marks listener replay callbacks as replayed', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'cached task', agentType: 'codex' }
},
'conn-1'
)
const listener = vi.fn()
server.setListener(listener)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
isReplay: true,
payload: expect.objectContaining({ state: 'working', prompt: 'cached task' })
})
)
})
it('unsubscribes status-change listeners without removing the remaining listeners', () => {
const server = new AgentHookServer()
const removed = vi.fn()
@@ -1235,6 +1259,242 @@ describe('AgentHookServer listener replay', () => {
}
})
it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-by-claude'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-by-claude'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-2824-permission-target'
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear by tool use id when tool input is not previewable', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-approved-opaque'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-approved-opaque'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
agentType: 'claude',
toolName: 'BespokeTool'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' },
tool_use_id: 'toolu-permission-owner-opaque'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-a' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'BespokeTool',
tool_input: { opaque: 'request-b' },
tool_use_id: 'toolu-other-opaque'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'BespokeTool'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when another tool use completes after permission', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-permission-owner'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-other-subagent'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('keeps Claude permission visible when an explicit agent type reports another tool use id', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postClaudeHook = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
await postClaudeHook({
hook_event_name: 'PreToolUse',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-permission-owner-type'
})
await postClaudeHook({
hook_event_name: 'PermissionRequest',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
await postClaudeHook({
hook_event_name: 'PostToolUse',
agent_type: 'main',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-other-type'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
} finally {
server.stop()
}
})
it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -1765,6 +2025,110 @@ describe('AgentHookServer listener replay', () => {
}
})
it('tracks Codex agent statuses from form-encoded managed hook posts', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const listener = vi.fn()
server.setListener(listener)
const postCodexHook = async (payload: Record<string, unknown>): Promise<void> => {
const params = new URLSearchParams({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
env: 'production',
version: env.ORCA_AGENT_HOOK_VERSION ?? '',
payload: JSON.stringify(payload)
})
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: params
})
expect(response.status).toBe(204)
}
await postCodexHook({
hook_event_name: 'UserPromptSubmit',
prompt: 'ship codex hook status'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
state: 'working',
agentType: 'codex',
prompt: 'ship codex hook status',
toolName: undefined,
toolInput: undefined
})
])
await postCodexHook({
hook_event_name: 'PreToolUse',
tool_name: 'exec_command',
tool_input: { cmd: 'pnpm test', workdir: '/repo' }
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
agentType: 'codex',
prompt: 'ship codex hook status',
toolName: 'exec_command',
toolInput: 'pnpm test'
})
])
await postCodexHook({
hook_event_name: 'PermissionRequest',
tool_name: 'exec_command',
tool_input: { cmd: 'git push', workdir: '/repo' }
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'waiting',
agentType: 'codex',
prompt: 'ship codex hook status',
toolName: 'exec_command',
toolInput: 'git push'
})
])
await postCodexHook({
hook_event_name: 'Stop',
last_assistant_message: 'done'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
agentType: 'codex',
prompt: 'ship codex hook status',
lastAssistantMessage: 'done'
})
])
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: expect.objectContaining({
state: 'done',
agentType: 'codex',
prompt: 'ship codex hook status',
lastAssistantMessage: 'done'
})
})
)
} finally {
server.stop()
}
})
it('accepts Hermes plugin hook posts on /hook/hermes', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -2291,11 +2655,11 @@ describe('Codex hook normalization', () => {
})
describe('Gemini hook normalization', () => {
it('PreToolUse surfaces toolName + toolInput', () => {
it('BeforeTool surfaces toolName + toolInput', () => {
const result = _internals.normalizeHookPayload(
'gemini',
buildBody({
hook_event_name: 'PreToolUse',
hook_event_name: 'BeforeTool',
tool_name: 'read_file',
tool_input: { path: '/src/index.ts' }
}),
@@ -2310,7 +2674,7 @@ describe('Gemini hook normalization', () => {
const result = _internals.normalizeHookPayload(
'gemini',
buildBody({
hook_event_name: 'PreToolUse',
hook_event_name: 'BeforeTool',
tool_name: 'run_shell_command',
args: { command: 'git status' }
}),
@@ -2324,7 +2688,7 @@ describe('Gemini hook normalization', () => {
_internals.normalizeHookPayload(
'gemini',
buildBody({
hook_event_name: 'PreToolUse',
hook_event_name: 'BeforeTool',
tool_name: 'read_file',
tool_input: { path: '/stale.ts' }
}),
@@ -2930,6 +3294,30 @@ describe('Pi hook normalization', () => {
expect(result?.payload.prompt).toBe('rename this fn')
})
it('OMP uses Pi-compatible events but keeps OMP agent attribution', () => {
const started = _internals.normalizeHookPayload(
'omp',
buildBody({ hook_event_name: 'before_agent_start', prompt: 'status for omp' }),
'production'
)
expect(started?.payload).toMatchObject({
state: 'working',
prompt: 'status for omp',
agentType: 'omp'
})
const done = _internals.normalizeHookPayload(
'omp',
buildBody({ hook_event_name: 'agent_end' }),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'status for omp',
agentType: 'omp'
})
})
it('agent_start without a prompt keeps the cached prompt from the current turn', () => {
_internals.normalizeHookPayload(
'pi',
+89 -18
View File
@@ -251,6 +251,8 @@ function isClaudePermissionResumingApprovedTool(
previous: EnrichedAgentHookEventPayload,
next: AgentHookEventPayload
): boolean {
const previousToolUseId = previous.toolUseId?.trim() || undefined
const nextToolUseId = next.toolUseId?.trim() || undefined
const previousAgentId = previous.toolAgentId?.trim() || undefined
const nextAgentId = next.toolAgentId?.trim() || undefined
const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined
@@ -269,19 +271,84 @@ function isClaudePermissionResumingApprovedTool(
hasMatchingConcreteAgentId &&
previous.payload.toolInput === undefined &&
next.payload.toolInput === undefined
const hasMatchingToolUseId =
previousToolUseId !== undefined && previousToolUseId === nextToolUseId
const hasConflictingToolUseId =
previousToolUseId !== undefined &&
nextToolUseId !== undefined &&
previousToolUseId !== nextToolUseId
const sameUnknownInputFromToolUseId =
hasMatchingToolUseId &&
previous.payload.toolInput === undefined &&
next.payload.toolInput === undefined
return (
next.hookEventName === 'PreToolUse' &&
typeof next.toolUseId === 'string' &&
next.toolUseId.trim().length > 0 &&
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
nextToolUseId !== undefined &&
!hasConflictingToolUseId &&
// Why: subagents can share `agent_type`; a concrete agent id is the
// strongest available signal that the permission owner resumed execution.
(hasMatchingConcreteAgentId || hasSameExplicitAgentType) &&
// Claude's approval path omits identity but preserves the original
// tool_use_id on PostToolUse, so that exact id is also a safe clear signal.
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
sameToolName &&
(sameKnownToolInput || sameUnknownInputFromConcreteAgent)
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
)
}
function shouldInheritClaudeToolUseIdForPermission(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): boolean {
if (
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'working' ||
previous.hookEventName !== 'PreToolUse' ||
typeof previous.toolUseId !== 'string' ||
previous.toolUseId.trim().length === 0 ||
next.payload.agentType !== 'claude' ||
next.payload.state !== 'waiting' ||
next.hookEventName !== 'PermissionRequest' ||
next.toolUseId !== undefined
) {
return false
}
const sameKnownToolInput =
previous.payload.toolInput !== undefined &&
previous.payload.toolInput === next.payload.toolInput
const sameUnknownToolInput =
previous.payload.toolInput === undefined && next.payload.toolInput === undefined
if (
previous.toolAgentId !== next.toolAgentId ||
previous.toolAgentType !== next.toolAgentType ||
previous.payload.toolName === undefined ||
previous.payload.toolName !== next.payload.toolName ||
(!sameKnownToolInput && !sameUnknownToolInput)
) {
return false
}
return true
}
function attachClaudePermissionToolUseId(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): AgentHookEventPayload {
const inheritedToolUseId = previous?.toolUseId
if (
!shouldInheritClaudeToolUseIdForPermission(previous, next) ||
typeof inheritedToolUseId !== 'string'
) {
return next
}
return {
...next,
// Why: Claude emits PermissionRequest without tool_use_id, then reports the
// approved command as PostToolUse with the original PreToolUse id.
toolUseId: inheritedToolUseId
}
}
export class AgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
@@ -335,7 +402,7 @@ export class AgentHookServer {
// listener is the bare AgentHookEventPayload because the shared module
// never reads from this map; only this class does, and only enriched
// values are ever inserted.
listener(payload as EnrichedAgentHookEventPayload)
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
}
@@ -466,7 +533,8 @@ export class AgentHookServer {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (previous && shouldKeepClaudePermissionVisible(previous, payload)) {
const effectivePayload = attachClaudePermissionToolUseId(previous, payload)
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
return previous
}
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
@@ -474,9 +542,9 @@ export class AgentHookServer {
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
payload.payload.state === 'done' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
effectivePayload.payload.state === 'done' &&
previous.payload.agentType === effectivePayload.payload.agentType &&
previous.payload.prompt === effectivePayload.payload.prompt &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
) {
return previous
@@ -484,19 +552,22 @@ export class AgentHookServer {
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
payload.payload.state === 'working' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
(payload.isReplay === true ||
(payload.hasExplicitPrompt !== true &&
effectivePayload.payload.state === 'working' &&
previous.payload.agentType === effectivePayload.payload.agentType &&
previous.payload.prompt === effectivePayload.payload.prompt &&
(effectivePayload.isReplay === true ||
(effectivePayload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) {
return previous
}
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(payload.paneKey)
if (
effectivePayload.payload.state !== 'done' ||
effectivePayload.payload.lastAssistantMessage
) {
this.clearAssistantMessageRetry(effectivePayload.paneKey)
}
const enriched = this.attachStatusTiming(payload)
const enriched = this.attachStatusTiming(effectivePayload)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
+54 -1
View File
@@ -13,9 +13,22 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const testState = {
fakeHomeDir: ''
fakeHomeDir: '',
userDataDir: '',
previousUserDataPath: undefined as string | undefined
}
vi.mock('electron', () => ({
app: {
getPath: (name: string) => {
if (name === 'userData') {
return testState.userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
}
}
}))
vi.mock('node:os', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const actual = await vi.importActual<typeof import('node:os')>('node:os')
@@ -30,11 +43,22 @@ const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTr
beforeEach(() => {
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-'))
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-'))
testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = testState.userDataDir
})
afterEach(() => {
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
rmSync(testState.userDataDir, { recursive: true, force: true })
if (testState.previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath
}
testState.fakeHomeDir = ''
testState.userDataDir = ''
testState.previousUserDataPath = undefined
})
describe('markCursorWorkspaceTrusted', () => {
@@ -119,10 +143,20 @@ describe('markCodexProjectTrusted', () => {
const realpath = realpathSync(workspace)
markCodexProjectTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const runtimeConfigPath = join(
testState.userDataDir,
'codex-runtime-home',
'home',
'config.toml'
)
expect(existsSync(configPath)).toBe(true)
expect(existsSync(runtimeConfigPath)).toBe(true)
const written = readFileSync(configPath, 'utf-8')
const runtimeWritten = readFileSync(runtimeConfigPath, 'utf-8')
expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(written).toContain('trust_level = "trusted"')
expect(runtimeWritten).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(runtimeWritten).toContain('trust_level = "trusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
@@ -133,7 +167,9 @@ describe('markCodexProjectTrusted', () => {
const realpath = realpathSync(workspace)
try {
const codexDir = join(testState.fakeHomeDir, '.codex')
const runtimeCodexDir = join(testState.userDataDir, 'codex-runtime-home', 'home')
mkdirSync(codexDir, { recursive: true })
mkdirSync(runtimeCodexDir, { recursive: true })
writeFileSync(
join(codexDir, 'config.toml'),
[
@@ -146,14 +182,31 @@ describe('markCodexProjectTrusted', () => {
].join('\n'),
'utf-8'
)
writeFileSync(
join(runtimeCodexDir, 'config.toml'),
[
'sandbox_mode = "workspace-write"',
'',
`[projects."${escapeTomlBasicString(realpath)}"]`,
'notes = "keep-runtime"',
'trust_level = "untrusted"',
''
].join('\n'),
'utf-8'
)
markCodexProjectTrusted(workspace)
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8')
expect(written).toContain('model = "gpt-5.5"')
expect(written).toContain('notes = "keep"')
expect(written).toContain('trust_level = "trusted"')
expect(written).not.toContain('trust_level = "untrusted"')
expect(runtimeWritten).toContain('sandbox_mode = "workspace-write"')
expect(runtimeWritten).toContain('notes = "keep-runtime"')
expect(runtimeWritten).toContain('trust_level = "trusted"')
expect(runtimeWritten).not.toContain('trust_level = "untrusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
+4
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
/**
@@ -109,6 +110,9 @@ export function markCodexProjectTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configPath = join(homedir(), '.codex', 'config.toml')
upsertProjectTrustLevel(configPath, absPath, 'trusted')
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
// preset must also update the runtime config Codex will actually read.
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
}
function canonicalize(p: string): string {
@@ -75,6 +75,8 @@ describe('AntigravityHookService', () => {
expect(script).toContain('/hook/antigravity')
expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}')
expect(script).toContain('payload=$(cat)')
expect(script).toContain("payload='{}'")
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
expect(script).toContain('{"decision":""}')
})
@@ -154,6 +156,8 @@ describe('AntigravityHookService', () => {
)
expect(script).toContain('/hook/antigravity')
expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT')
expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }')
expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }')
})
})
+4 -2
View File
@@ -120,7 +120,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'fi',
'payload=$(cat)',
'if [ -z "$payload" ]; then',
' exit 0',
// Why: some Antigravity hook events can arrive without stdin. Still post
// the event name so Orca shows a status row instead of silently dropping it.
" payload='{}'",
'fi',
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
@@ -158,7 +160,7 @@ function getWindowsWrapperScript(eventName: string): string {
}
function buildWindowsAntigravityHookPostCommand(): string {
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"`
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"`
}
function isRecord(value: unknown): value is Record<string, unknown> {
+61 -7
View File
@@ -70,6 +70,7 @@ function mockBrowserManager(
getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId),
getGuestWebContentsId: vi.fn(() => null),
ensureWebviewVisible: vi.fn(async () => () => {}),
acquireAutomationVisibility: vi.fn(async () => () => {}),
...overrides
} as unknown as BrowserManager
}
@@ -420,6 +421,55 @@ describe('AgentBrowserBridge', () => {
expect(snapshotIdx).toBeLessThan(clickIdx)
})
it('acquires an automation visibility lease while running snapshot commands', async () => {
const lifecycleEvents: string[] = []
const restore = vi.fn(() => {
lifecycleEvents.push('restore-100')
})
const acquireAutomationVisibility = vi.fn(async (webContentsId: number) => {
lifecycleEvents.push(`acquire-${webContentsId}`)
return restore
})
const b = new AgentBrowserBridge(
mockBrowserManager(undefined, undefined, {
acquireAutomationVisibility
})
)
b.setActiveTab(100)
let releaseSnapshot: (() => void) | null = null
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
if (args.includes('snapshot')) {
lifecycleEvents.push('command-snapshot')
releaseSnapshot = () => {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
}
return
}
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
}
)
const snapshot = b.snapshot()
await vi.waitFor(() => {
expect(releaseSnapshot).not.toBeNull()
})
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot'])
expect(restore).not.toHaveBeenCalled()
releaseSnapshot!()
await expect(snapshot).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'tree' })
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot', 'restore-100'])
})
it('serializes screenshot visibility prep across sessions', async () => {
vi.useFakeTimers()
try {
@@ -432,8 +482,8 @@ describe('AgentBrowserBridge', () => {
['tab-2', 'wt-2']
])
const lifecycleEvents: string[] = []
const ensureWebviewVisibleMock = vi.fn(async (webContentsId: number) => {
lifecycleEvents.push(`ensure-${webContentsId}`)
const acquireAutomationVisibilityMock = vi.fn(async (webContentsId: number) => {
lifecycleEvents.push(`acquire-${webContentsId}`)
return () => {
lifecycleEvents.push(`restore-${webContentsId}`)
}
@@ -449,7 +499,7 @@ describe('AgentBrowserBridge', () => {
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, worktrees, {
ensureWebviewVisible: ensureWebviewVisibleMock
acquireAutomationVisibility: acquireAutomationVisibilityMock
})
)
b.setActiveTab(1, 'wt-1')
@@ -489,9 +539,9 @@ describe('AgentBrowserBridge', () => {
await Promise.resolve()
await vi.advanceTimersByTimeAsync(300)
expect(lifecycleEvents).toContain('ensure-1')
expect(lifecycleEvents).toContain('acquire-1')
expect(lifecycleEvents).toContain('command-orca-tab-tab-1')
expect(lifecycleEvents).not.toContain('ensure-2')
expect(lifecycleEvents).not.toContain('acquire-2')
expect(releaseFirstScreenshot).not.toBeNull()
releaseFirstScreenshot!()
@@ -503,7 +553,9 @@ describe('AgentBrowserBridge', () => {
await Promise.resolve()
await Promise.resolve()
expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(lifecycleEvents.indexOf('ensure-2'))
expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(
lifecycleEvents.indexOf('acquire-2')
)
await vi.advanceTimersByTimeAsync(300)
await expect(second).resolves.toEqual({
@@ -665,7 +717,9 @@ describe('AgentBrowserBridge', () => {
)
const runningSnapshot = bridge.snapshot()
await Promise.resolve()
await vi.waitFor(() => {
expect(resolveRunningCommand).not.toBeNull()
})
const destroyPromise = (
bridge as unknown as { destroySession: (name: string) => Promise<void> }
+68 -24
View File
@@ -86,6 +86,11 @@ type AgentBrowserExecOptions = {
timeoutError?: BrowserError
}
type EnqueueTargetedCommandOptions = {
ensureSession?: boolean
ensureVisible?: boolean
}
type AgentBrowserBridgeOptions = {
onTabsChanged?: (worktreeId?: string) => void
}
@@ -388,10 +393,10 @@ export class AgentBrowserBridge {
private readonly sessions = new Map<string, SessionState>()
private readonly commandQueues = new Map<string, QueuedCommand[]>()
private readonly processingQueues = new Set<string>()
// Why: screenshot prep temporarily changes shared renderer visibility/focus
// state. Per-session queues only serialize commands within one browser tab, so
// concurrent screenshots on different tabs can otherwise interleave
// ensureWebviewVisible()/restore and blank each other's capture.
// Why: screenshot prep temporarily changes shared renderer paintability state.
// Per-session queues only serialize commands within one browser tab, so
// concurrent screenshots on different tabs can otherwise interleave hidden
// surface leases and blank each other's capture.
private screenshotTurn: Promise<void> = Promise.resolve()
private readonly agentBrowserBin: string
// Why: when a process swap destroys a session that had active intercept patterns,
@@ -1139,9 +1144,14 @@ export class AgentBrowserBridge {
): Promise<BrowserScreenshotResult> {
// Why: agent-browser writes the screenshot to a temp file and returns
// { "path": "/tmp/screenshot-xxx.png" }. We read the file and return base64.
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format)
})
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format)
},
{ ensureVisible: false }
)
}
async fullPageScreenshot(
@@ -1149,14 +1159,19 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<BrowserScreenshotResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => {
return this.captureFullPageScreenshotCommand(
sessionName,
target.webContentsId,
500,
format === 'jpeg' ? 'jpeg' : 'png'
)
})
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName, target) => {
return this.captureFullPageScreenshotCommand(
sessionName,
target.webContentsId,
500,
format === 'jpeg' ? 'jpeg' : 'png'
)
},
{ ensureVisible: false }
)
}
private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult {
@@ -1180,12 +1195,12 @@ export class AgentBrowserBridge {
return this.withSerializedScreenshotAccess(async () => {
const session = this.sessions.get(sessionName)
const restore = session
? await this.browserManager.ensureWebviewVisible(session.webContentsId)
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
// Why: after focusing the window and unhiding the webview, the compositor
// Why: after acquiring the hidden paintability lease, the compositor
// needs a short settle period to produce a painted frame. Waiting inside
// the global screenshot lock prevents another tab from stealing visible
// the global screenshot lock prevents another tab from changing lease
// state before the current capture actually hits CDP.
await new Promise((r) => setTimeout(r, settleMs))
const raw = await this.execAgentBrowser(sessionName, commandArgs)
@@ -1205,11 +1220,11 @@ export class AgentBrowserBridge {
return this.withSerializedScreenshotAccess(async () => {
const session = this.sessions.get(sessionName)
const restore = session
? await this.browserManager.ensureWebviewVisible(session.webContentsId)
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
// Why: full-page capture still depends on the guest compositor producing
// a fresh frame. Wait after activating the target webview so the direct
// a fresh frame. Wait after the target webview is paintable so the direct
// CDP capture sees the live page instead of a stale surface.
await new Promise((r) => setTimeout(r, settleMs))
const wc = this.getWebContents(webContentsId)
@@ -1691,8 +1706,11 @@ export class AgentBrowserBridge {
worktreeId: string | undefined,
execute: (sessionName: string) => Promise<T>
): Promise<T> {
return this.enqueueTargetedCommand(worktreeId, undefined, async (sessionName) =>
execute(sessionName)
return this.enqueueTargetedCommand(
worktreeId,
undefined,
async (sessionName) => execute(sessionName),
{ ensureVisible: false }
)
}
@@ -1700,7 +1718,7 @@ export class AgentBrowserBridge {
worktreeId: string | undefined,
browserPageId: string | undefined,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: { ensureSession?: boolean } = {}
options: EnqueueTargetedCommandOptions = {}
): Promise<T> {
const target = this.resolveCommandTarget(worktreeId, browserPageId)
const sessionName = `orca-tab-${target.browserPageId}`
@@ -1716,7 +1734,13 @@ export class AgentBrowserBridge {
this.commandQueues.set(sessionName, queue)
}
queue.push({
execute: (() => execute(sessionName, target)) as () => Promise<unknown>,
execute: (() =>
this.executeWithVisibleTarget(
sessionName,
target,
execute,
options
)) as () => Promise<unknown>,
resolve: resolve as (value: unknown) => void,
reject
})
@@ -1724,6 +1748,26 @@ export class AgentBrowserBridge {
})
}
private async executeWithVisibleTarget<T>(
sessionName: string,
target: ResolvedBrowserCommandTarget,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: EnqueueTargetedCommandOptions
): Promise<T> {
if (options.ensureVisible === false) {
return execute(sessionName, target)
}
// Why: inactive browser panes are display:none in the renderer; the
// automation lease makes only this target paintable without selecting it.
const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId)
try {
return await execute(sessionName, target)
} finally {
restore()
}
}
private async processQueue(sessionName: string): Promise<void> {
if (this.processingQueues.has(sessionName)) {
return
+62 -1
View File
@@ -11,7 +11,12 @@ vi.mock('electron', () => ({
session: { fromPartition: sessionFromPartitionMock }
}))
import { importCookiesFromFile, detectInstalledBrowsers } from './browser-cookie-import'
import {
buildChromiumCookieInsertParams,
importCookiesFromFile,
detectInstalledBrowsers,
type ChromiumCookieColumnInfo
} from './browser-cookie-import'
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -233,3 +238,59 @@ describe('detectInstalledBrowsers', () => {
}
})
})
describe('buildChromiumCookieInsertParams', () => {
it('fills target-only NOT NULL Chromium cookie columns instead of inserting null', () => {
const decryptedValue = Buffer.from('decrypted-cookie-value')
const columns: ChromiumCookieColumnInfo[] = [
{ name: 'creation_utc', type: 'INTEGER', notnull: 1 },
{ name: 'host_key', type: 'TEXT', notnull: 1 },
{ name: 'top_frame_site_key', type: 'TEXT', notnull: 1 },
{ name: 'name', type: 'TEXT', notnull: 1 },
{ name: 'value', type: 'TEXT', notnull: 1 },
{ name: 'encrypted_value', type: 'BLOB', notnull: 1 },
{ name: 'source_port', type: 'INTEGER', notnull: 1 },
{ name: 'last_update_utc', type: 'INTEGER', notnull: 1 },
{ name: 'has_cross_site_ancestor', type: 'INTEGER', notnull: 1, dflt_value: '0' }
]
const sourceRow = {
creation_utc: 133_000_000_000_000n,
host_key: '.example.com',
name: 'sid'
}
const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue)
expect(params).toEqual([
133_000_000_000_000n,
'.example.com',
'',
'sid',
decryptedValue,
Buffer.alloc(0),
-1,
133_000_000_000_000n,
0
])
})
it('preserves null for nullable columns without defaults', () => {
const decryptedValue = Buffer.from('decrypted-cookie-value')
const columns: ChromiumCookieColumnInfo[] = [
{ name: 'creation_utc', type: 'INTEGER', notnull: 1 },
{ name: 'host_key', type: 'TEXT', notnull: 1 },
{ name: 'nullable_metadata', type: 'TEXT', notnull: 0 },
{ name: 'target_only_nullable_metadata', type: 'TEXT', notnull: 0 },
{ name: 'last_update_utc', type: 'INTEGER', notnull: 1 }
]
const sourceRow = {
creation_utc: 133_000_000_000_000n,
host_key: '.example.com',
nullable_metadata: null
}
const params = buildChromiumCookieInsertParams(columns, sourceRow, decryptedValue)
expect(params).toEqual([133_000_000_000_000n, '.example.com', null, null, 133_000_000_000_000n])
})
})
+129 -24
View File
@@ -32,6 +32,13 @@ function getDiagLogPath(): string {
}
return _diagLog
}
function reasonWithDiagLog(reason: string): string {
return `${reason} Details were written to ${getDiagLogPath()}.`
}
function describeImportError(err: unknown): string {
const raw = err instanceof Error && err.message ? err.message : String(err)
return raw.replace(/\s+/g, ' ').slice(0, 180)
}
function diag(msg: string): void {
const line = `[${new Date().toISOString()}] ${msg}\n`
try {
@@ -761,6 +768,120 @@ type EncryptionKeyResult = {
fallbackKey?: Buffer
}
export type ChromiumCookieColumnInfo = {
name: string
type?: string
notnull?: number | bigint
dflt_value?: unknown
}
function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null {
if (raw === null || raw === undefined) {
return null
}
if (typeof raw !== 'string') {
return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw)
}
const trimmed = raw.trim()
if (!trimmed || trimmed.toUpperCase() === 'NULL') {
return null
}
if (/^X''$/i.test(trimmed) || type.includes('BLOB')) {
return Buffer.alloc(0)
}
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed.slice(1, -1).replaceAll("''", "'")
}
if (type.includes('INT')) {
const numeric = Number(trimmed)
return Number.isFinite(numeric) ? numeric : 0
}
return trimmed
}
function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null {
if (value instanceof Uint8Array) {
return Buffer.from(value)
}
if (value === undefined || value === null) {
return null
}
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') {
return value
}
return String(value)
}
function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean {
return Number(column.notnull ?? 0) !== 0
}
function fallbackChromiumCookieColumnValue(
column: ChromiumCookieColumnInfo,
sourceRow: Record<string, unknown>
): string | number | bigint | Buffer | null {
const type = (column.type ?? '').toUpperCase()
const defaultValue = parseSqliteDefaultValue(column.dflt_value, type)
if (defaultValue !== null) {
return defaultValue
}
if (!isSqliteNotNull(column)) {
return null
}
switch (column.name) {
case 'value':
case 'encrypted_value':
return Buffer.alloc(0)
case 'top_frame_site_key':
return ''
case 'source_port':
return -1
case 'last_update_utc':
return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0
default:
if (type.includes('BLOB')) {
return Buffer.alloc(0)
}
if (type.includes('INT')) {
return 0
}
return ''
}
}
export function buildChromiumCookieInsertParams(
targetColumns: ChromiumCookieColumnInfo[],
sourceRow: Record<string, unknown>,
decryptedValue: Buffer
): (string | number | bigint | Buffer | null)[] {
return targetColumns.map((column) => {
if (column.name === 'encrypted_value') {
return Buffer.alloc(0)
}
if (column.name === 'value') {
return decryptedValue
}
const sourceHasColumn = Object.prototype.hasOwnProperty.call(sourceRow, column.name)
const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null
if (sourceValue !== null) {
return sourceValue
}
if (sourceHasColumn && !isSqliteNotNull(column)) {
return null
}
// Why: Chromium cookie DB columns drift across Chrome/Electron versions.
// Missing NOT NULL target columns must get safe Chromium defaults, not NULL.
return fallbackChromiumCookieColumnValue(column, sourceRow)
})
}
function getEncryptionKey(
keychainService: string,
keychainAccount: string,
@@ -1382,10 +1503,10 @@ export async function importCookiesFromBrowser(
sourceDb = new DatabaseSync(tmpCookiesPath, { readOnly: true, readBigInts: true })
stagingDb = new DatabaseSync(stagingCookiesPath)
type PragmaRow = { name: string }
const targetCols: string[] = (
stagingDb.prepare('PRAGMA table_info(cookies)').all() as PragmaRow[]
).map((r) => r.name)
const targetColumnInfo = stagingDb
.prepare('PRAGMA table_info(cookies)')
.all() as ChromiumCookieColumnInfo[]
const targetCols: string[] = targetColumnInfo.map((r) => r.name)
const colList = targetCols.join(', ')
stagingDb.exec('DELETE FROM cookies')
@@ -1511,25 +1632,7 @@ export async function importCookiesFromBrowser(
expirationDate: expiresUtc > 0 ? expiresUtc : undefined
})
const params = targetCols.map((col) => {
if (col === 'encrypted_value') {
return Buffer.alloc(0)
}
if (col === 'value') {
return decryptedValue
}
const v = sourceRow[col]
if (v instanceof Uint8Array) {
return Buffer.from(v)
}
if (v === undefined || v === null) {
return null
}
if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'string') {
return v
}
return String(v)
})
const params = buildChromiumCookieInsertParams(targetColumnInfo, sourceRow, decryptedValue)
insertStmt.run(...params)
imported++
}
@@ -1636,7 +1739,9 @@ export async function importCookiesFromBrowser(
diag(` SQLite import failed: ${err}`)
return {
ok: false,
reason: `Could not import cookies from ${browser.label}. Check the diag log for details.`
reason: reasonWithDiagLog(
`Could not import cookies from ${browser.label}: ${describeImportError(err)}.`
)
}
}
}

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